Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ describe('follow-up submit routing', () => {
);
});

it('routes burst input through the selected follow-up lane', () => {
it('routes an explicit steer while leaving plain Enter to interrupt-and-send', () => {
assert.equal(
resolveFollowUpModeAtSubmit({
hasActiveTurn: true,
}),
'queue',
undefined,
);
assert.equal(
resolveFollowUpModeAtSubmit({
Expand All @@ -60,6 +60,13 @@ describe('follow-up submit routing', () => {
}),
'steer',
);
assert.equal(
resolveFollowUpModeAtSubmit({
requestedMode: 'queue',
hasActiveTurn: true,
}),
'queue',
);
});

it('starts a normal turn only when no active-turn witness exists', () => {
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1865,10 +1865,11 @@ function AppShellContent({
const runningTurnIds = sessionId
? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds
: undefined;
const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds });
const followUpAtSubmit = !slashCommand
? resolveFollowUpModeAtSubmit({
requestedMode: metadata?.followUpMode,
hasActiveTurn: hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }),
hasActiveTurn,
})
: undefined;
if (sessionId && followUpAtSubmit) {
Expand All @@ -1879,6 +1880,29 @@ function AppShellContent({
if (queued) delete retractedWorkspaceReferencesRef.current[sessionId];
return queued;
}
// Plain Enter during a live turn: interrupt first, then fall through to a
// new root send so the typed message stops the runaway loop (#4083).
// Interrupt retracts any prior queue entries, so this must precede send.
if (sessionId && hasActiveTurn && !slashCommand) {
try {
const stopped = await window.maka.sessions.stop(sessionId, { source: 'stop_button' });
if (stopped?.kind === 'interrupted') {
for (const messageId of stopped.retractedMessageIds) {
removeTransientMessage(sessionId, messageId);
}
}
} catch (error) {
if (activeIdRef.current === sessionId) {
const copy = getDesktopConversationCopy(uiLocale).actions;
showSessionError(
sessionId,
copy.operationFailedTitle,
localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
);
}
return false;
}
}
if (
revisionSend &&
revision &&
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/renderer/follow-up-submit-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ export function hasActiveTurnAtSubmit(input: {

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

export function mergeWorkspaceReferences(
Expand Down
162 changes: 160 additions & 2 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6064,6 +6064,161 @@ describe('AiSdkBackend model history', () => {
assert.equal(usage?.type === 'token_usage' ? usage.total : undefined, 2);
});

test('stops an unbounded loop after consecutive identical empty tool steps', async () => {
// Desktop often omits maxSteps. A model that repeats the same tool call with
// no visible text would otherwise flood empty assistant rows forever (#4083).
const loop = countingToolLoopModel(undefined, true);
const durable = durableTurnHarness('turn-empty-loop', 'keep going');
const backend = createTestAiSdkBackend({
sessionId: 'session-1',
header: header(),
appendMessage: async () => {},
connection: connection(),
apiKey: 'sk-test',
modelId: 'mock-model-id',
modelFactory: () => loop.model,
tools: [testTool('Read', z.object({ path: z.string() }))],
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
newId: idGenerator(),
now: monotonicClock(),
});

const events = await drainDurably(backend.send(durable.input()), durable);
assert.equal(loop.callCount(), 3);
assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit');
assert.equal(
events.filter((event) => event.type === 'tool_start').length,
3,
);
});

test('stops an unbounded loop when Responses reasoning-end is only an empty carrier', async () => {
// OpenAI Responses emits `{ kind: 'thinking', text: '' }` at reasoning-end
// whenever provider metadata is present. That carrier must not count as
// visible thinking, or identical textless tool steps never reach the cap.
const reasoningMetadata = {
openai: {
itemId: 'rs_empty',
reasoningEncryptedContent: 'encrypted-carrier',
},
};
let calls = 0;
const model = new MockLanguageModelV4({
doStream: async () => {
calls += 1;
return {
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start', warnings: [] },
{ type: 'reasoning-start', id: 'r1', providerMetadata: reasoningMetadata },
{ type: 'reasoning-end', id: 'r1', providerMetadata: reasoningMetadata },
{
type: 'tool-call',
toolCallId: `tool-${calls}`,
toolName: 'Read',
input: JSON.stringify({ path: 'notes.md' }),
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
usage: emptyUsage(),
},
],
initialDelayInMs: null,
chunkDelayInMs: null,
}),
};
},
});
const durable = durableTurnHarness('turn-empty-responses-loop', 'keep going');
const backend = createTestAiSdkBackend({
sessionId: 'session-1',
header: header(),
appendMessage: async () => {},
connection: connection(),
apiKey: 'sk-test',
modelId: 'mock-model-id',
modelFactory: () => model,
tools: [testTool('Read', z.object({ path: z.string() }))],
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
newId: idGenerator(),
now: monotonicClock(),
});

const events = await drainDurably(backend.send(durable.input()), durable);
assert.equal(calls, 3);
assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit');
assert.equal(events.filter((event) => event.type === 'tool_start').length, 3);
});

test('stops an unbounded loop when only a thinking signature accompanies identical tool calls', async () => {
// Anthropic can emit omitted/redacted reasoning as a standalone signature
// with no text. The signature must persist for replay, but must not count
// as visible thinking or the empty-step cap never fires (#4083).
let calls = 0;
const model = new MockLanguageModelV4({
doStream: async () => {
calls += 1;
return {
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start', warnings: [] },
{ type: 'reasoning-start', id: 'r1' },
{
type: 'reasoning-delta',
id: 'r1',
delta: '',
providerMetadata: { anthropic: { signature: `sig-${calls}` } },
},
{ type: 'reasoning-end', id: 'r1' },
{
type: 'tool-call',
toolCallId: `tool-${calls}`,
toolName: 'Read',
input: JSON.stringify({ path: 'notes.md' }),
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
usage: emptyUsage(),
},
],
initialDelayInMs: null,
chunkDelayInMs: null,
}),
};
},
});
const durable = durableTurnHarness('turn-empty-signature-loop', 'keep going');
const backend = createTestAiSdkBackend({
sessionId: 'session-1',
header: header(),
appendMessage: async () => {},
connection: connection(),
apiKey: 'sk-test',
modelId: 'mock-model-id',
modelFactory: () => model,
tools: [testTool('Read', z.object({ path: z.string() }))],
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
newId: idGenerator(),
now: monotonicClock(),
});

const events = await drainDurably(backend.send(durable.input()), durable);
assert.equal(calls, 3);
assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit');
assert.equal(events.filter((event) => event.type === 'tool_start').length, 3);
assert.ok(
events.some(
(event) =>
event.type === 'thinking_complete' &&
event.signature !== undefined &&
event.text === '',
),
'signature-only reasoning must still persist',
);
});

test('aborting during post-stream persistence wins over step-limit completion', async () => {
const loop = countingToolLoopModel();
const gate = makeGate();
Expand Down Expand Up @@ -14960,7 +15115,10 @@ function planExecution(status: 'completed' | 'cancelled') {
};
}

function countingToolLoopModel(toolCallsBeforeStop?: number): {
function countingToolLoopModel(
toolCallsBeforeStop?: number,
repeatToolInput = false,
): {
model: MockLanguageModelV4;
callCount: () => number;
} {
Expand Down Expand Up @@ -14990,7 +15148,7 @@ function countingToolLoopModel(toolCallsBeforeStop?: number): {
type: 'tool-call',
toolCallId: `tool-${calls}`,
toolName: 'Read',
input: JSON.stringify({ path: `notes-${calls}.md` }),
input: JSON.stringify({ path: repeatToolInput ? 'notes.md' : `notes-${calls}.md` }),
},
{
type: 'finish',
Expand Down
57 changes: 56 additions & 1 deletion packages/runtime/src/ai-sdk-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,14 @@ const MAX_WAITING_CODE_MODE_CELLS = 1;
const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10;
const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1;
const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1;
/**
* Desktop interactive turns often omit `maxSteps`, so a model that keeps
* emitting the same tool-call with no visible assistant text can loop forever
* and flood the transcript with empty AI replies (#4083). Only identical,
* textless steps are counted: ordinary multi-step tool workflows and an
* explicit `maxSteps` remain authoritative.
*/
const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3;
const PROVIDER_RETRY_BASE_DELAY_MS = 1_000;
const PROVIDER_RETRY_MAX_DELAY_MS = 32_000;
const PROVIDER_RETRY_JITTER_FACTOR = 0.25;
Expand Down Expand Up @@ -2175,7 +2183,11 @@ export class AiSdkBackend implements AgentBackend {
let providerOutcome: ModelStepOutcome;
let finishReason: ModelFinishReason = 'stop';
let terminalProviderError: unknown;
let consecutiveIdenticalEmptySteps = 0;
let previousEmptyStepSignature: string | undefined;
agentLoop: for (;;) {
let stepSawVisibleText = false;
let stepSawThinking = false;
await this.drainSteeringInto(scope, input, queue);
if (this.input.loadTurnRuntimeEvents) {
requestMessages = await loadDurableTurnProjection();
Expand Down Expand Up @@ -2362,7 +2374,10 @@ export class AiSdkBackend implements AgentBackend {
stepTextPartStartOffset = stepText.length;
} else if (event.kind === 'text') {
stepText += event.text;
if (event.text.length > 0) attemptSawText = true;
if (event.text.length > 0) {
attemptSawText = true;
stepSawVisibleText = true;
}
queue.push({
type: 'text_delta',
id: this.newId(),
Expand All @@ -2381,6 +2396,12 @@ export class AiSdkBackend implements AgentBackend {
stepTextPartStartOffset,
);
} else if (event.kind === 'thinking') {
// OpenAI Responses emits an empty thinking carrier at
// `reasoning-end` whenever provider metadata is present. That
// is not user-visible progress, so it must not reset the
// empty-step loop cap (#4083). Persistence still uses
// `sawStepThinking` so the encrypted carrier round-trips.
if (event.text.length > 0) stepSawThinking = true;
sawStepThinking = true;
stepThinking += event.text;
if (event.text.length > 0) attemptSawThinking = true;
Expand Down Expand Up @@ -2426,6 +2447,9 @@ export class AiSdkBackend implements AgentBackend {
text: event.text,
} satisfies ThinkingDeltaEvent);
} else if (event.kind === 'thinking-signature') {
// A standalone signature is omitted/redacted reasoning, not
// user-visible progress. Persist it for replay, but do not
// reset the empty-step loop cap (#4083).
attemptSawContinuationMetadata = true;
stepSignature = event.signature;
} else if (event.kind === 'provider-tool-input') {
Expand Down Expand Up @@ -2799,6 +2823,33 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
lastCompletedStepHadToolResult = returnedToolCalls.length > 0;
const emptyStepSignature =
!stepSawVisibleText && !stepSawThinking && returnedToolCalls.length > 0
? JSON.stringify(
returnedToolCalls.map(({ toolName, input }) => ({ toolName, input })),
)
: undefined;
if (
maxSteps === undefined &&
emptyStepSignature !== undefined &&
!scope.loopStopRequested
) {
consecutiveIdenticalEmptySteps =
emptyStepSignature === previousEmptyStepSignature
? consecutiveIdenticalEmptySteps + 1
: 1;
previousEmptyStepSignature = emptyStepSignature;
if (consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS) {
// The model is repeating the same tool-only step with no visible
// progress. Stop as a failed tool-step cap rather than reporting a
// successful end_turn with no answer (#4083).
scope.loopStopReason = 'step_limit';
scope.loopStopRequested = true;
}
} else {
consecutiveIdenticalEmptySteps = 0;
previousEmptyStepSignature = undefined;
}
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
sandboxBoundaryFinalizationStep ||
Expand Down Expand Up @@ -2842,6 +2893,10 @@ export class AiSdkBackend implements AgentBackend {
!scope.loopStopRequested &&
!scope.aborted
) {
// A redirected prompt deserves a fresh empty-step streak; otherwise
// a prior empty run would stop the turn before the steer can land.
consecutiveIdenticalEmptySteps = 0;
previousEmptyStepSignature = undefined;
currentStepMessageId = this.newId();
continue agentLoop;
}
Expand Down
Loading