Skip to content

Commit 685d05c

Browse files
luoxuanzaoQoder-AI
andauthored
fix(chat): steer interrupts the in-flight turn; silence aborted receipts (#40)
- steer() stamps priority 'now' so the CLI interrupts the active turn instead of queuing the text as a follow-up turn - aborted results (terminal_reason aborted_streaming/aborted_tools) are treated as cancellations: no error chunks, no receipt, and the router keeps the handler alive for the successor turn - send early-return paths now fully clean up streaming state and refresh the queue indicator Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 5666578 commit 685d05c

10 files changed

Lines changed: 236 additions & 13 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ coverage/
4747
# Temporary screenshots from automated UI verification
4848
/.cu_crop_*.png
4949
/.verify-shots/
50+
/.cu_tmp/

‎CHANGELOG.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ version with its date and start a fresh empty `[Unreleased]` above it.
2020
delete actions, and entries drain one per turn end in FIFO order.
2121
- Steer an in-flight turn from the send queue (Codex-style): while a
2222
response is streaming, each queued entry without images gains a Steer
23-
action that injects it into the running turn at the next step boundary,
24-
rendering it as a user message immediately instead of waiting for the
23+
action that interrupts the running turn and handles the message
24+
immediately, rendering it as a user message instead of waiting for the
2525
turn to end.
2626
- Drag notes or folders from the Obsidian file explorer into the chat
2727
composer: they are inserted as `@note` / `@folder/` context mentions at
@@ -58,6 +58,14 @@ version with its date and start a fresh empty `[Unreleased]` above it.
5858
- The settings language row now updates its own label and description
5959
immediately when the display language is changed, instead of keeping
6060
the previous language's text until the settings tab is reopened.
61+
- Steering an in-flight turn now interrupts it immediately instead of
62+
queuing the message as a follow-up turn that only arrives after the
63+
running response finishes, and the interrupted turn no longer shows an
64+
error receipt.
65+
- Send failure paths (agent service unavailable or initialization
66+
failure) now fully clean up the streaming state and queue indicator,
67+
so the composer no longer stays stuck as "streaming" after an early
68+
send error.
6169

6270
## [1.0.5] - 2026-08-18
6371

‎src/features/chat/controllers/input-controller.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,9 @@ export class InputController {
332332
new Notice('Failed to initialize agent service. Please try again.');
333333
streamController.hideThinkingIndicator();
334334
state.isStreaming = false;
335+
// Re-render the send queue so steer buttons drop now that the turn
336+
// ended before it started (same contract as the normal exit path).
337+
this.updateQueueIndicator();
335338
this.activeStreamingAssistantMessage = null;
336339
this.resetRuntimeMessageBoundaryState();
337340
return;
@@ -341,6 +344,9 @@ export class InputController {
341344
const agentService = this.getAgentService();
342345
if (!agentService) {
343346
new Notice('Agent service not available. Please reload the plugin.');
347+
streamController.hideThinkingIndicator();
348+
state.isStreaming = false;
349+
this.updateQueueIndicator();
344350
this.activeStreamingAssistantMessage = null;
345351
this.resetRuntimeMessageBoundaryState();
346352
return;
@@ -553,8 +559,9 @@ export class InputController {
553559

554560
/**
555561
* Codex-style steering: inject a queued turn into the in-flight turn. The
556-
* CLI applies it at the next step boundary. Returns false when steering is
557-
* not possible right now; the message should stay queued then.
562+
* CLI interrupts the active turn and handles the text immediately
563+
* (`priority: 'now'`). Returns false when steering is not possible right
564+
* now; the message should stay queued then.
558565
*/
559566
steerQueuedTurn(turn: QueuedChatTurn): boolean {
560567
if (!this.canSteerQueuedTurn()) return false;

‎src/qoder/runtime/qoder-chat-runtime.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,8 +1163,9 @@ export class QoderChatRuntime implements ChatRuntime {
11631163
}
11641164

11651165
/**
1166-
* Inject a text message into the in-flight turn. The CLI applies it at the
1167-
* next step boundary (between tool calls). Fails when no turn is running.
1166+
* Inject a text message into the in-flight turn (steering). The channel
1167+
* stamps `priority: 'now'` so the CLI interrupts the active turn and
1168+
* handles the text immediately. Fails when no turn is running.
11681169
*/
11691170
steerTurn(text: string): boolean {
11701171
return this.messageChannel?.steer(text) ?? false;

‎src/qoder/runtime/qoder-message-channel.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,19 @@ export class QoderMessageChannel implements AsyncIterable<SDKUserMessage> {
134134
}
135135

136136
/**
137-
* Deliver a text message into the in-flight turn (steering). The CLI picks
138-
* it up at the next step boundary. Returns false when there is no active
139-
* turn or no consumer waiting; callers should keep the message queued then.
137+
* Deliver a text message into the in-flight turn (steering). The message
138+
* carries `priority: 'now'` so the CLI interrupts the active turn and
139+
* handles it immediately — without it the CLI defaults to 'next', queues
140+
* the text as a follow-up turn, and the reply arrives long after, out of
141+
* band. Returns false when there is no active turn or no consumer
142+
* waiting; callers should keep the message queued then.
140143
*/
141144
steer(text: string): boolean {
142145
if (this.closed || !this.turnActive || !this.resolveNext) return false;
143146
const resolve = this.resolveNext;
144147
this.resolveNext = null;
145-
resolve({ value: this.pendingToMessage({ type: 'text', content: text }), done: false });
148+
const message = this.pendingToMessage({ type: 'text', content: text });
149+
resolve({ value: { ...message, priority: 'now' }, done: false });
146150
return true;
147151
}
148152

‎src/qoder/runtime/qoder-response-router.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Notice } from 'obsidian';
33

44
import type { AutoTurnCallback } from '../../core/runtime/types';
55
import type { StreamChunk } from '../../core/types';
6-
import { transformSDKMessage } from '../stream/transform-qoder-message';
6+
import { isAbortedResult,transformSDKMessage } from '../stream/transform-qoder-message';
77
import { isContextWindowEvent, isSessionInitEvent, isStreamChunk } from '../stream/type-guards';
88
import type { SessionInitEvent } from '../stream/types';
99
import { TOOL_ENTER_PLAN_MODE } from '../tools/tool-names';
@@ -160,7 +160,13 @@ export class QoderResponseRouter {
160160
if (handler) {
161161
handler.resetStreamText();
162162
handler.resetStreamThinking();
163-
handler.onDone();
163+
// A deliberately aborted turn (Esc or priority-'now' steer) is followed
164+
// by its successor turn or by consumer teardown — never by session
165+
// idle with the handler still waiting. Keep the handler alive so the
166+
// successor's chunks keep streaming into the same turn generator.
167+
if (!isAbortedResult(message)) {
168+
handler.onDone();
169+
}
164170
} else {
165171
await this.flushAutoTurnBuffer();
166172
}

‎src/qoder/stream/transform-qoder-message.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ function isResultError(message: { type: 'result'; subtype: string }): message is
128128
return !!message.subtype && message.subtype !== 'success';
129129
}
130130

131+
const ABORT_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']);
132+
133+
/**
134+
* A result whose `terminal_reason` marks a deliberate abort (Esc interrupt or
135+
* a priority-'now' steer), per the CLI protocol. The CLI emits these as
136+
* error_during_execution results, but they are cancellation receipts, not
137+
* failures — the CLI's own headless consumer maps them to SIGINT's exit code.
138+
*/
139+
export function isAbortedResult(
140+
message: { type: string; terminal_reason?: string | null },
141+
): boolean {
142+
return message.type === 'result'
143+
&& !!message.terminal_reason
144+
&& ABORT_TERMINAL_REASONS.has(message.terminal_reason);
145+
}
146+
131147
function normalizeQoderModelId(model: string): string {
132148
const normalized = model.trim().toLowerCase();
133149
const qoderIndex = normalized.indexOf('qoder-');
@@ -591,7 +607,7 @@ export function* transformSDKMessage(
591607
}
592608
options.usageState.clear();
593609
}
594-
if (isResultError(message)) {
610+
if (isResultError(message) && !isAbortedResult(message)) {
595611
const errors = message.errors.filter((error) => error.trim().length > 0);
596612
if (errors.length === 0) {
597613
yield { type: 'error', content: `Result error: ${message.subtype}` };

‎tests/unit/qoder/runtime/qoder-message-channel.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,52 @@ describe('QoderMessageChannel', () => {
418418
expect(channel.isTurnActive()).toBe(false);
419419
});
420420
});
421+
422+
describe('steer', () => {
423+
it('delivers the text with priority now so the CLI interrupts the active turn', async () => {
424+
const iterator = channel[Symbol.asyncIterator]();
425+
const firstPromise = iterator.next();
426+
channel.enqueue(createTextUserMessage('original'));
427+
await firstPromise;
428+
429+
// The consumer waits for the next message while the turn is active.
430+
const secondPromise = iterator.next();
431+
await Promise.resolve();
432+
433+
expect(channel.steer('steered text')).toBe(true);
434+
435+
const steered = await secondPromise;
436+
expect(steered.done).toBe(false);
437+
expect(steered.value.type).toBe('user');
438+
expect(steered.value.message.content).toBe('steered text');
439+
// Without 'now' the CLI defaults to 'next' and defers the text as a
440+
// follow-up turn instead of interrupting the in-flight one.
441+
expect(steered.value.priority).toBe('now');
442+
});
443+
444+
it('returns false when no turn is active', async () => {
445+
const iterator = channel[Symbol.asyncIterator]();
446+
const pending = iterator.next();
447+
await Promise.resolve();
448+
449+
expect(channel.steer('ignored')).toBe(false);
450+
451+
// The rejected steer left the consumer waiting; a normal enqueue
452+
// delivers without any priority stamp.
453+
channel.enqueue(createTextUserMessage('follow-up'));
454+
const result = await pending;
455+
expect(result.value.message.content).toBe('follow-up');
456+
expect(result.value.priority).toBeUndefined();
457+
});
458+
459+
it('returns false when no consumer is waiting', async () => {
460+
const iterator = channel[Symbol.asyncIterator]();
461+
const firstPromise = iterator.next();
462+
channel.enqueue(createTextUserMessage('original'));
463+
await firstPromise;
464+
465+
// Turn is active but the consumer has not requested the next message.
466+
expect(channel.steer('ignored')).toBe(false);
467+
});
468+
});
421469
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { SDKResultError } from '@qoder-ai/qoder-agent-sdk';
2+
import { buildSDKMessage } from '@test/helpers/sdk-messages';
3+
4+
import type { StreamChunk } from '@/core/types';
5+
import { QoderResponseRouter } from '@/qoder/runtime/qoder-response-router';
6+
import { createResponseHandler } from '@/qoder/runtime/types';
7+
8+
function createRouterHarness() {
9+
const onTurnComplete = jest.fn();
10+
const deps = {
11+
turnTracker: {
12+
getTransformOptions: () => ({}),
13+
fetchContextUsage: async () => null,
14+
bufferUsage: (chunk: StreamChunk) => chunk,
15+
updateContextWindow: () => null,
16+
record: jest.fn(),
17+
},
18+
getCurrentQuery: () => null,
19+
getMessageChannel: () => ({ onTurnComplete }),
20+
getConfiguredModel: () => 'qoder-sonnet-4-5',
21+
getConfiguredContextWindow: () => undefined,
22+
getSessionId: () => null,
23+
onSessionInit: jest.fn(),
24+
onPlanModeEntered: jest.fn(),
25+
};
26+
const router = new QoderResponseRouter(deps as never);
27+
28+
const chunks: StreamChunk[] = [];
29+
const events = { onDone: jest.fn(), onError: jest.fn() };
30+
const handler = createResponseHandler({
31+
id: 'test-handler',
32+
onChunk: chunk => chunks.push(chunk),
33+
onDone: events.onDone,
34+
onError: events.onError,
35+
});
36+
router.register(handler);
37+
38+
return { router, handler, chunks, events, onTurnComplete };
39+
}
40+
41+
function buildAbortResult(): SDKResultError {
42+
return buildSDKMessage({
43+
type: 'result',
44+
subtype: 'error_during_execution',
45+
errors: ['Operation aborted'],
46+
terminal_reason: 'aborted_streaming',
47+
}) as SDKResultError;
48+
}
49+
50+
describe('QoderResponseRouter', () => {
51+
describe('aborted turn results', () => {
52+
it('keeps the active handler alive so the successor turn keeps streaming', async () => {
53+
const { router, events, onTurnComplete } = createRouterHarness();
54+
55+
await router.route(buildAbortResult());
56+
57+
expect(onTurnComplete).toHaveBeenCalledTimes(1);
58+
expect(events.onDone).not.toHaveBeenCalled();
59+
});
60+
61+
it('completes the handler on the successor turn result', async () => {
62+
const { router, events } = createRouterHarness();
63+
64+
await router.route(buildAbortResult());
65+
expect(events.onDone).not.toHaveBeenCalled();
66+
67+
await router.route(buildSDKMessage({ type: 'result', subtype: 'success' }));
68+
expect(events.onDone).toHaveBeenCalledTimes(1);
69+
});
70+
71+
it('routes nothing to the handler for an abort receipt', async () => {
72+
const { router, chunks } = createRouterHarness();
73+
74+
await router.route(buildAbortResult());
75+
76+
expect(chunks).toEqual([]);
77+
});
78+
79+
it('completes the handler for non-abort error results', async () => {
80+
const { router, events } = createRouterHarness();
81+
82+
await router.route(buildSDKMessage({
83+
type: 'result',
84+
subtype: 'error_during_execution',
85+
errors: ['Credit limit reached'],
86+
}));
87+
88+
expect(events.onDone).toHaveBeenCalledTimes(1);
89+
});
90+
});
91+
});

‎tests/unit/qoder/stream/transform-qoder-message.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,6 +1175,47 @@ describe('transformSDKMessage', () => {
11751175
]);
11761176
});
11771177

1178+
it('stays silent for an aborted_streaming result', () => {
1179+
// Receipt for a deliberately interrupted turn (Esc or priority-'now' steer);
1180+
// the user already saw the interruption, no bubble output is wanted.
1181+
const message = msg({
1182+
type: 'result',
1183+
subtype: 'error_during_execution',
1184+
errors: ['Operation aborted'],
1185+
terminal_reason: 'aborted_streaming',
1186+
});
1187+
1188+
const results = [...transformSDKMessage(message)];
1189+
1190+
expect(results.filter(result => result.type === 'error' || result.type === 'notice')).toEqual([]);
1191+
});
1192+
1193+
it('stays silent for an aborted_tools result', () => {
1194+
const message = msg({
1195+
type: 'result',
1196+
subtype: 'error_during_execution',
1197+
errors: ['Operation aborted'],
1198+
terminal_reason: 'aborted_tools',
1199+
});
1200+
1201+
const results = [...transformSDKMessage(message)];
1202+
1203+
expect(results.filter(result => result.type === 'error' || result.type === 'notice')).toEqual([]);
1204+
});
1205+
1206+
it('keeps error events for non-abort terminal reasons', () => {
1207+
const message = msg({
1208+
type: 'result',
1209+
subtype: 'error_during_execution',
1210+
errors: ['Something failed'],
1211+
terminal_reason: 'something_else',
1212+
});
1213+
1214+
const results = [...transformSDKMessage(message)];
1215+
1216+
expect(results).toContainEqual({ type: 'error', content: 'Something failed' });
1217+
});
1218+
11781219
it('yields context_window with 1M for [1m] models', () => {
11791220
const message = msg({
11801221
type: 'result',

0 commit comments

Comments
 (0)