Skip to content

Commit 41ad5a7

Browse files
committed
fix(desktop,runtime): interrupt and bound empty assistant loops
Interrupt the active desktop turn before sending a plain Enter follow-up, and clean up transient messages retracted by the stop operation. Bound repeated textless tool steps when no explicit maxSteps is configured, while preserving normal multi-step workflows and explicit step limits. Fixes #4083
1 parent 82b36dc commit 41ad5a7

6 files changed

Lines changed: 130 additions & 16 deletions

File tree

‎apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ describe('follow-up submit routing', () => {
4646
);
4747
});
4848

49-
it('routes burst input through the selected follow-up lane', () => {
49+
it('routes an explicit steer while leaving plain Enter to interrupt-and-send', () => {
5050
assert.equal(
5151
resolveFollowUpModeAtSubmit({
5252
hasActiveTurn: true,
5353
}),
54-
'queue',
54+
undefined,
5555
);
5656
assert.equal(
5757
resolveFollowUpModeAtSubmit({
@@ -60,6 +60,13 @@ describe('follow-up submit routing', () => {
6060
}),
6161
'steer',
6262
);
63+
assert.equal(
64+
resolveFollowUpModeAtSubmit({
65+
requestedMode: 'queue',
66+
hasActiveTurn: true,
67+
}),
68+
'queue',
69+
);
6370
});
6471

6572
it('starts a normal turn only when no active-turn witness exists', () => {

‎apps/desktop/src/renderer/app-shell.tsx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1865,10 +1865,11 @@ function AppShellContent({
18651865
const runningTurnIds = sessionId
18661866
? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds
18671867
: undefined;
1868+
const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds });
18681869
const followUpAtSubmit = !slashCommand
18691870
? resolveFollowUpModeAtSubmit({
18701871
requestedMode: metadata?.followUpMode,
1871-
hasActiveTurn: hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }),
1872+
hasActiveTurn,
18721873
})
18731874
: undefined;
18741875
if (sessionId && followUpAtSubmit) {
@@ -1879,6 +1880,29 @@ function AppShellContent({
18791880
if (queued) delete retractedWorkspaceReferencesRef.current[sessionId];
18801881
return queued;
18811882
}
1883+
// Plain Enter during a live turn: interrupt first, then fall through to a
1884+
// new root send so the typed message stops the runaway loop (#4083).
1885+
// Interrupt retracts any prior queue entries, so this must precede send.
1886+
if (sessionId && hasActiveTurn && !slashCommand) {
1887+
try {
1888+
const stopped = await window.maka.sessions.stop(sessionId, { source: 'stop_button' });
1889+
if (stopped?.kind === 'interrupted') {
1890+
for (const messageId of stopped.retractedMessageIds) {
1891+
removeTransientMessage(sessionId, messageId);
1892+
}
1893+
}
1894+
} catch (error) {
1895+
if (activeIdRef.current === sessionId) {
1896+
const copy = getDesktopConversationCopy(uiLocale).actions;
1897+
showSessionError(
1898+
sessionId,
1899+
copy.operationFailedTitle,
1900+
localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
1901+
);
1902+
}
1903+
return false;
1904+
}
1905+
}
18821906
if (
18831907
revisionSend &&
18841908
revision &&

‎apps/desktop/src/renderer/follow-up-submit-routing.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,14 @@ export function hasActiveTurnAtSubmit(input: {
3434

3535
export function resolveFollowUpModeAtSubmit(input: {
3636
requestedMode?: FollowUpMode;
37-
hasActiveTurn: boolean;
37+
/**
38+
* Retained so call sites keep compiling. Mid-turn plain Enter no longer
39+
* queues; the send path interrupts and opens a new root (#4083).
40+
*/
41+
hasActiveTurn?: boolean;
3842
}): FollowUpMode | undefined {
3943
if (input.requestedMode) return input.requestedMode;
40-
// Mid-turn submits always queue; Shift+Enter carries the one-shot steer as
41-
// the requested mode.
42-
return input.hasActiveTurn ? 'queue' : undefined;
44+
return undefined;
4345
}
4446

4547
export function mergeWorkspaceReferences(

‎packages/runtime/src/__tests__/ai-sdk-backend.test.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6064,6 +6064,34 @@ describe('AiSdkBackend model history', () => {
60646064
assert.equal(usage?.type === 'token_usage' ? usage.total : undefined, 2);
60656065
});
60666066

6067+
test('stops an unbounded loop after consecutive identical empty tool steps', async () => {
6068+
// Desktop often omits maxSteps. A model that repeats the same tool call with
6069+
// no visible text would otherwise flood empty assistant rows forever (#4083).
6070+
const loop = countingToolLoopModel(undefined, true);
6071+
const durable = durableTurnHarness('turn-empty-loop', 'keep going');
6072+
const backend = createTestAiSdkBackend({
6073+
sessionId: 'session-1',
6074+
header: header(),
6075+
appendMessage: async () => {},
6076+
connection: connection(),
6077+
apiKey: 'sk-test',
6078+
modelId: 'mock-model-id',
6079+
modelFactory: () => loop.model,
6080+
tools: [testTool('Read', z.object({ path: z.string() }))],
6081+
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
6082+
newId: idGenerator(),
6083+
now: monotonicClock(),
6084+
});
6085+
6086+
const events = await drainDurably(backend.send(durable.input()), durable);
6087+
assert.equal(loop.callCount(), 3);
6088+
assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit');
6089+
assert.equal(
6090+
events.filter((event) => event.type === 'tool_start').length,
6091+
3,
6092+
);
6093+
});
6094+
60676095
test('aborting during post-stream persistence wins over step-limit completion', async () => {
60686096
const loop = countingToolLoopModel();
60696097
const gate = makeGate();
@@ -14960,7 +14988,10 @@ function planExecution(status: 'completed' | 'cancelled') {
1496014988
};
1496114989
}
1496214990

14963-
function countingToolLoopModel(toolCallsBeforeStop?: number): {
14991+
function countingToolLoopModel(
14992+
toolCallsBeforeStop?: number,
14993+
repeatToolInput = false,
14994+
): {
1496414995
model: MockLanguageModelV4;
1496514996
callCount: () => number;
1496614997
} {
@@ -14990,7 +15021,7 @@ function countingToolLoopModel(toolCallsBeforeStop?: number): {
1499015021
type: 'tool-call',
1499115022
toolCallId: `tool-${calls}`,
1499215023
toolName: 'Read',
14993-
input: JSON.stringify({ path: `notes-${calls}.md` }),
15024+
input: JSON.stringify({ path: repeatToolInput ? 'notes.md' : `notes-${calls}.md` }),
1499415025
},
1499515026
{
1499615027
type: 'finish',

‎packages/runtime/src/ai-sdk-backend.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,14 @@ const MAX_WAITING_CODE_MODE_CELLS = 1;
912912
const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10;
913913
const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1;
914914
const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1;
915+
/**
916+
* Desktop interactive turns often omit `maxSteps`, so a model that keeps
917+
* emitting the same tool-call with no visible assistant text can loop forever
918+
* and flood the transcript with empty AI replies (#4083). Only identical,
919+
* textless steps are counted: ordinary multi-step tool workflows and an
920+
* explicit `maxSteps` remain authoritative.
921+
*/
922+
const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3;
915923
const PROVIDER_RETRY_BASE_DELAY_MS = 1_000;
916924
const PROVIDER_RETRY_MAX_DELAY_MS = 32_000;
917925
const PROVIDER_RETRY_JITTER_FACTOR = 0.25;
@@ -2175,7 +2183,11 @@ export class AiSdkBackend implements AgentBackend {
21752183
let providerOutcome: ModelStepOutcome;
21762184
let finishReason: ModelFinishReason = 'stop';
21772185
let terminalProviderError: unknown;
2186+
let consecutiveIdenticalEmptySteps = 0;
2187+
let previousEmptyStepSignature: string | undefined;
21782188
agentLoop: for (;;) {
2189+
let stepSawVisibleText = false;
2190+
let stepSawThinking = false;
21792191
await this.drainSteeringInto(scope, input, queue);
21802192
if (this.input.loadTurnRuntimeEvents) {
21812193
requestMessages = await loadDurableTurnProjection();
@@ -2362,7 +2374,10 @@ export class AiSdkBackend implements AgentBackend {
23622374
stepTextPartStartOffset = stepText.length;
23632375
} else if (event.kind === 'text') {
23642376
stepText += event.text;
2365-
if (event.text.length > 0) attemptSawText = true;
2377+
if (event.text.length > 0) {
2378+
attemptSawText = true;
2379+
stepSawVisibleText = true;
2380+
}
23662381
queue.push({
23672382
type: 'text_delta',
23682383
id: this.newId(),
@@ -2381,6 +2396,7 @@ export class AiSdkBackend implements AgentBackend {
23812396
stepTextPartStartOffset,
23822397
);
23832398
} else if (event.kind === 'thinking') {
2399+
stepSawThinking = true;
23842400
sawStepThinking = true;
23852401
stepThinking += event.text;
23862402
if (event.text.length > 0) attemptSawThinking = true;
@@ -2426,6 +2442,7 @@ export class AiSdkBackend implements AgentBackend {
24262442
text: event.text,
24272443
} satisfies ThinkingDeltaEvent);
24282444
} else if (event.kind === 'thinking-signature') {
2445+
stepSawThinking = true;
24292446
attemptSawContinuationMetadata = true;
24302447
stepSignature = event.signature;
24312448
} else if (event.kind === 'provider-tool-input') {
@@ -2799,6 +2816,33 @@ export class AiSdkBackend implements AgentBackend {
27992816
...(providerStepUsage ? { usage: providerStepUsage } : {}),
28002817
});
28012818
lastCompletedStepHadToolResult = returnedToolCalls.length > 0;
2819+
const emptyStepSignature =
2820+
!stepSawVisibleText && !stepSawThinking && returnedToolCalls.length > 0
2821+
? JSON.stringify(
2822+
returnedToolCalls.map(({ toolName, input }) => ({ toolName, input })),
2823+
)
2824+
: undefined;
2825+
if (
2826+
maxSteps === undefined &&
2827+
emptyStepSignature !== undefined &&
2828+
!scope.loopStopRequested
2829+
) {
2830+
consecutiveIdenticalEmptySteps =
2831+
emptyStepSignature === previousEmptyStepSignature
2832+
? consecutiveIdenticalEmptySteps + 1
2833+
: 1;
2834+
previousEmptyStepSignature = emptyStepSignature;
2835+
if (consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS) {
2836+
// The model is repeating the same tool-only step with no visible
2837+
// progress. Stop as a failed tool-step cap rather than reporting a
2838+
// successful end_turn with no answer (#4083).
2839+
scope.loopStopReason = 'step_limit';
2840+
scope.loopStopRequested = true;
2841+
}
2842+
} else {
2843+
consecutiveIdenticalEmptySteps = 0;
2844+
previousEmptyStepSignature = undefined;
2845+
}
28022846
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
28032847
if (
28042848
sandboxBoundaryFinalizationStep ||
@@ -2842,6 +2886,10 @@ export class AiSdkBackend implements AgentBackend {
28422886
!scope.loopStopRequested &&
28432887
!scope.aborted
28442888
) {
2889+
// A redirected prompt deserves a fresh empty-step streak; otherwise
2890+
// a prior empty run would stop the turn before the steer can land.
2891+
consecutiveIdenticalEmptySteps = 0;
2892+
previousEmptyStepSignature = undefined;
28452893
currentStepMessageId = this.newId();
28462894
continue agentLoop;
28472895
}

‎packages/ui/src/composer.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,8 +1193,9 @@ export const Composer = forwardRef<
11931193

11941194
function submit(event: FormEvent<HTMLFormElement>) {
11951195
event.preventDefault();
1196-
// Mid-turn the host queues the draft as a follow-up by default; only
1197-
// Shift+Enter (see onInputKeyDown) steers it into the active Turn.
1196+
// Mid-turn the host used to queue the draft as a follow-up by default; plain
1197+
// Enter now interrupts and starts a new root (#4083). Shift+Enter (see
1198+
// onInputKeyDown) still steers into the active Turn.
11981199
void sendCurrent();
11991200
}
12001201

@@ -1253,7 +1254,8 @@ export const Composer = forwardRef<
12531254
}
12541255
if (event.key !== 'Enter') return;
12551256
// Alt+Enter always inserts a line break. During a running turn, Shift+Enter
1256-
// steers this one draft into the active Turn; plain Enter queues it.
1257+
// steers this one draft into the active Turn; plain Enter interrupts and
1258+
// starts a new root (#4083).
12571259
if (event.altKey || (event.shiftKey && !props.streaming)) {
12581260
event.preventDefault();
12591261
document.execCommand('insertLineBreak');
@@ -1338,9 +1340,9 @@ export const Composer = forwardRef<
13381340
// One slot, one button, two states — Astryx's send/stop toggle. Mid-turn an
13391341
// empty draft has nothing to submit, so the slot is Stop; the moment there is
13401342
// a draft, handing it over is the only meaningful action there and the button
1341-
// returns to Send (the host queues it as a follow-up). Stop is not lost in
1342-
// that window: Esc interrupts from the input, which is where the hands already
1343-
// are.
1343+
// returns to Send (the host interrupts then starts a new root, #4083). Stop is
1344+
// not lost in that window: Esc interrupts from the input, which is where the
1345+
// hands already are.
13441346
const stopShown = props.streaming === true && !text.trim();
13451347
// The pending plate renders the follow-up queue only: steering entries are
13461348
// already handed to the active Turn and leave the plate at that moment.

0 commit comments

Comments
 (0)