Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
106 changes: 106 additions & 0 deletions apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,117 @@

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { FollowUpMode } from '@maka/core/events';
import type { ComposerSendMetadata } from '@maka/ui';
import {
createRevisionAwareOnSend,
mergeWorkspaceReferences,
type RevisionSendPorts,
} from '../../renderer/features/conversation/testing.js';

interface ObservedCall {
kind: 'enqueue' | 'send';
mode?: FollowUpMode;
text?: string;
}

function routingPorts(calls: ObservedCall[]): RevisionSendPorts<{ sourceSessionId: string; draftSessionId: string }> {
return {
shellCopy: {
sideChatUnavailableTitle: '',
sideChatUnavailableDescription: '',
sideChatContextPendingTitle: '',
sideChatContextPendingDescription: '',
swarmModeEnabledTitle: '',
swarmModeDisabledTitle: '',
swarmModeStatusDescription: '',
graphModeEnabledTitle: '',
graphModeDisabledTitle: '',
graphModeStatusDescription: '',
graphHistoryTitle: '',
graphHistoryDescription: '',
},
toastApi: { info() {} },
activeIdRef: { current: 'session-a' },
revisionDraftRef: { current: null },
composerRef: { current: null },
retractedWorkspaceReferencesRef: { current: {} },
captureStaging: () => ({
draftKey: 'draft',
hasPendingContext: false,
hasStagedQuotes: false,
submittableAttachments: undefined,
directoryOptions: {},
quotesForSend: () => undefined,
retainAttachments: () => () => {},
clearSubmittedContext: () => {},
clearQuotes: () => {},
}),
prepareRevisionSend: async () => true,
send: async (text) => {
calls.push({ kind: 'send', text });
return true;
},
enqueueFollowUp: async (_sessionId, text, mode) => {
calls.push({ kind: 'enqueue', mode, text });
return true;
},
settleNewTaskImageNoticeOwner: () => {},
commitRevisionDraft: () => {},
completeRevisionCopyAttempt: () => {},
parseSlashCommand: () => null,
mergeWorkspaceReferences,
rebaseWorkspaceFileReferences: (_source, _projected, references) => [...references],
revisionUnavailableCopy: {
revisionUnavailableTitle: '',
revisionAttachmentsUnsupported: '',
revisionCommandUnsupported: '',
},
compactSession: async () => true,
resolveNewTaskSessionHandler: () => () => {},
openSideChat: () => {},
getActiveOrchestrationMode: () => 'default',
setOrchestrationModeActive: async () => true,
};
}

describe('follow-up submit routing', () => {
it('queues an ordinary follow-up on a healthy turn without calling stop', async () => {
const calls: ObservedCall[] = [];
const ports = routingPorts(calls);
const send = createRevisionAwareOnSend({
...ports,
setNewTaskSendPending: () => {},
});
// Composer sets queue for plain Enter while a healthy Turn is running.
const metadata: ComposerSendMetadata = { followUpMode: 'queue' };
assert.equal(await send('keep going', metadata), true);
assert.deepEqual(calls, [{ kind: 'enqueue', mode: 'queue', text: 'keep going' }]);
});

it('keeps an explicit steer on the queue path and still does not stop', async () => {
const calls: ObservedCall[] = [];
const ports = routingPorts(calls);
assert.equal(
await createRevisionAwareOnSend({ ...ports, setNewTaskSendPending: () => {} })(
'change direction',
{ followUpMode: 'steer' },
),
true,
);
assert.deepEqual(calls, [{ kind: 'enqueue', mode: 'steer', text: 'change direction' }]);
});

it('sends a root turn when no follow-up mode is set, without calling stop', async () => {
const calls: ObservedCall[] = [];
const ports = routingPorts(calls);
assert.equal(
await createRevisionAwareOnSend({ ...ports, setNewTaskSendPending: () => {} })('hello'),
true,
);
assert.deepEqual(calls, [{ kind: 'send', text: 'hello' }]);
});

it('restores workspace references after queued text returns to the draft', () => {
assert.deepEqual(
mergeWorkspaceReferences(
Expand Down
212 changes: 209 additions & 3 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6439,6 +6439,208 @@ 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 empty tool steps alternate between signatures', async () => {
// A consecutive-only counter resets on A→B→A→B. The recent window must still
// treat that as no distinct progress once it fills with fewer distinct
// signatures than steps (#4083).
let calls = 0;
const model = new MockLanguageModelV4({
doStream: async () => {
calls += 1;
const path = calls % 2 === 1 ? 'notes-a.md' : 'notes-b.md';
return {
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start', warnings: [] },
{
type: 'tool-call',
toolCallId: `tool-${calls}`,
toolName: 'Read',
input: JSON.stringify({ path }),
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
usage: emptyUsage(),
},
],
initialDelayInMs: null,
chunkDelayInMs: null,
}),
};
},
});
const durable = durableTurnHarness('turn-empty-alternating-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, 6);
assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit');
assert.equal(events.filter((event) => event.type === 'tool_start').length, 6);
});

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',
);
});

for (const decision of ['cancel', 'commit', 'stop'] as const) {
test(`cooperative handoff ${decision} waits for the settled tool and gates the next request`, {
timeout: 5_000,
Expand Down Expand Up @@ -6537,8 +6739,9 @@ describe('AiSdkBackend model history', () => {
boundaries += 1;
return 'pause';
},
}))
})) {
events.push(event);
}
assert.equal(boundaries, 0);
assert.equal(
events.some((event) => event.type === 'complete'),
Expand Down Expand Up @@ -18066,7 +18269,10 @@ function planExecution(status: 'completed' | 'cancelled') {
};
}

function countingToolLoopModel(toolCallsBeforeStop?: number): {
function countingToolLoopModel(
toolCallsBeforeStop?: number,
repeatToolInput = false,
): {
model: MockLanguageModelV4;
callCount: () => number;
} {
Expand Down Expand Up @@ -18096,7 +18302,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
Loading
Loading