Skip to content
Closed
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
33 changes: 33 additions & 0 deletions docs/design/active-todo-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Active Todo Context

## Problem

`todo_write` presents the current list as a reminder only in its own tool
result. After more tool calls, that reminder loses salience and the model may
end the turn with unfinished items. The persisted todo file is unsuitable as
live control state because it can outlive the work chain that created it.

## Design

After a successful `todo_write`, keep a reminder containing only unfinished
items, keyed by the prompt ID that owns the work chain. Append it after function
responses on subsequent tool-result turns with the same owner in both the core
and ACP loops. This isolates ordinary prompts, cron jobs, and background
notifications even when they share a session. Clear the reminder when all todos
complete, a new work chain starts, or the session changes. Retry, continue, and
explicitly related automatic requests move the reminder to the new prompt ID
because they resume the same work chain. The repeated reminder is capped at
4,000 characters.

This does not change stop semantics or enable `todoStopGuard`. The guard remains
an optional bounded recovery after a model has already tried to stop; this
change instead preserves task context before that decision.

## Verification

- A successful write with unfinished items updates the session reminder.
- A completed list clears it.
- Core and ACP tool-result messages append the reminder after function results.
- ACP mid-turn user input remains last and therefore keeps precedence.
- An ordinary new prompt clears stale state while retry/continue retains it.
- Independent automatic turns are isolated; related automatic turns inherit.
101 changes: 96 additions & 5 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@ describe('Session', () => {
switchModel: switchModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getActiveTodoReminder: vi.fn().mockReturnValue(undefined),
setActiveTodoReminder: vi.fn(),
startActiveTodoWorkChain: vi.fn(),
startAutomaticActiveTodoWorkChain: vi.fn(),
assertCanStartTurn: vi.fn().mockResolvedValue(undefined),
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
getProjectRoot: vi.fn().mockReturnValue('/repo'),
Expand Down Expand Up @@ -759,6 +763,66 @@ describe('Session', () => {
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});

it('clears active todo context when an ordinary prompt starts', async () => {
mockChat.sendMessageStream = vi
.fn()
.mockImplementation(async () => createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'start different work' }],
});

expect(mockConfig.startActiveTodoWorkChain).toHaveBeenCalledWith(
'test-session-id########1',
undefined,
);

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'start different work' }],
retry: true,
} as PromptRequest);

expect(mockConfig.startActiveTodoWorkChain).toHaveBeenCalledWith(
'test-session-id########2',
'test-session-id########1',
);
});

it('continues active Todo context for related automatic turns', async () => {
mockChat.sendMessageStream = vi
.fn()
.mockImplementation(async () => createEmptyStream());
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'start work' }],
});
vi.mocked(mockConfig.startAutomaticActiveTodoWorkChain).mockClear();
const internals = session as unknown as {
relatedAgentIds: Set<string>;
};
internals.relatedAgentIds.add('related-agent');
const callback = mockBackgroundTaskRegistry.setNotificationCallback.mock
.calls[0][0] as (
displayText: string,
modelText: string,
meta: { agentId: string; status: string },
) => void;

callback('Background task completed.', '<task-notification/>', {
agentId: 'related-agent',
status: 'completed',
});

await vi.waitFor(() =>
expect(mockConfig.startAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
expect.stringContaining('########notification'),
'test-session-id########1',
),
);
});

it('holds the close gate until active turns settle', async () => {
let resolveTurn!: () => void;
const turnCompletion = new Promise<void>((resolve) => {
Expand Down Expand Up @@ -6056,9 +6120,26 @@ describe('Session', () => {
});

it('injects drained mid-turn user messages with tool responses', async () => {
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'file contents',
returnDisplay: 'file contents',
const todoReminder =
'<system-reminder>unfinished todo: check tests</system-reminder>';
const activeTodoReminders = new Map<string, string>();
vi.mocked(mockConfig.getActiveTodoReminder).mockImplementation(
(promptId) => activeTodoReminders.get(promptId),
);
vi.mocked(mockConfig.setActiveTodoReminder).mockImplementation(
(promptId, reminder) => {
if (reminder) activeTodoReminders.set(promptId, reminder);
},
);
const executeSpy = vi.fn().mockImplementation(async () => {
const promptId = core.promptIdContext.getStore();
if (promptId) {
mockConfig.setActiveTodoReminder(promptId, todoReminder);
}
return {
llmContent: 'file contents',
returnDisplay: 'file contents',
};
});
const tool = {
name: 'read_file',
Expand Down Expand Up @@ -6110,9 +6191,19 @@ describe('Session', () => {
const midTurnPart = {
text: '\n[User message received during tool execution]: please also check tests ',
};
expect(secondCall?.[1].message).toEqual(
expect.arrayContaining([midTurnPart]),
const nextMessage = secondCall?.[1].message as Part[];
const functionResponseIndex = nextMessage.findIndex(
(part) => part.functionResponse !== undefined,
);
const reminderIndex = nextMessage.findIndex(
(part) => part.text === todoReminder,
);
const midTurnIndex = nextMessage.findIndex(
(part) => part.text === midTurnPart.text,
);
expect(functionResponseIndex).toBeGreaterThanOrEqual(0);
expect(reminderIndex).toBeGreaterThan(functionResponseIndex);
expect(midTurnIndex).toBeGreaterThan(reminderIndex);
expect(
mockChatRecordingService.recordMidTurnUserMessage,
).toHaveBeenCalledWith([midTurnPart], ' please also check tests ');
Expand Down
70 changes: 55 additions & 15 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,7 @@ export class Session implements SessionContext {
*/
private followupAbort: AbortController | null = null;
private turn: number = 0;
private activeTodoWorkChainPromptId: string | undefined;
private readonly createdAt: number = Date.now();
/**
* Running cumulative usage for this session, snapshotted onto each todo/plan
Expand Down Expand Up @@ -1345,14 +1346,8 @@ export class Session implements SessionContext {
!this.config.getBareMode() &&
!this.config.isSafeMode();
this.todoStopGuard = new DaemonTodoStopGuard(todoStopGuardEnabled);
this.todoStopGuardBackgroundBaseline = todoStopGuardEnabled
? this.#captureTodoStopGuardBackgroundBaseline()
: {
agents: new Set(),
shells: new Set(),
monitors: new Set(),
wakeups: new Set(),
};
this.todoStopGuardBackgroundBaseline =
this.#captureTodoStopGuardBackgroundBaseline();

// Initialize modular components with this session as context
this.toolCallEmitter = new ToolCallEmitter(this);
Expand Down Expand Up @@ -2634,6 +2629,22 @@ export class Session implements SessionContext {
this.turn += 1;

const promptId = this.config.getSessionId() + '########' + this.turn;
const promptMetadata = (params as { _meta?: Record<string, unknown> })
._meta;
const continuesCurrentWorkChain =
(params as { retry?: boolean }).retry === true ||
promptMetadata?.[DAEMON_RETRY_META_KEY] === true ||
promptMetadata?.[DAEMON_CONTINUE_META_KEY] === true;
if (!continuesCurrentWorkChain && !this.todoStopGuard.enabled) {
this.#resetTodoStopGuardBackgroundLineage();
}
this.config.startActiveTodoWorkChain(
promptId,
continuesCurrentWorkChain
? this.activeTodoWorkChainPromptId
: undefined,
);
this.activeTodoWorkChainPromptId = promptId;
// Bind the prompt ID for the remainder of this turn, mirroring the
// sessionIdContext.run wrapper in #executePrompt. Shell subprocesses
// read it via getShellContextEnvVars (QWEN_CODE_PROMPT_ID) — without
Expand Down Expand Up @@ -3187,6 +3198,7 @@ export class Session implements SessionContext {
await this.#buildNextMessageAfterToolRun(
toolRun,
pendingSend.signal,
promptId,
onFullTurnModel,
);
nextMessage = nextAfterTools.message;
Expand Down Expand Up @@ -4127,6 +4139,7 @@ export class Session implements SessionContext {
const nextAfterTools = await this.#buildNextMessageAfterToolRun(
toolRun,
pendingSend.signal,
toolPromptId,
options.onFullTurnModel,
);
nextMessage = nextAfterTools.message;
Expand Down Expand Up @@ -4530,6 +4543,7 @@ export class Session implements SessionContext {
async #buildNextMessageAfterToolRun(
toolRun: RunToolResult,
abortSignal: AbortSignal,
promptId: string,
onFullTurnModel?: (model: string) => boolean,
): Promise<NextMessageAfterToolRun> {
if (toolRun.loopDetected) {
Expand All @@ -4550,7 +4564,12 @@ export class Session implements SessionContext {
if (hadMidTurnUserInput) {
this.todoStopGuard.acceptMidTurnUserInput();
}
const parts = [...toolRun.parts, ...drained.parts];
const activeTodoReminder = this.config.getActiveTodoReminder(promptId);
const parts = [
...toolRun.parts,
...(activeTodoReminder ? [{ text: activeTodoReminder }] : []),
...drained.parts,
];
return {
message: { role: 'user', parts },
hadMidTurnUserInput,
Expand Down Expand Up @@ -5131,9 +5150,9 @@ export class Session implements SessionContext {
async () => {
const ac = new AbortController();
this.cronAbortController = ac;
this.#prepareTodoStopGuardForAutomaticTurn(
this.#cronContinuesTodoStopGuardWorkChain(item),
);
const continuesCurrentWorkChain =
this.#cronContinuesTodoStopGuardWorkChain(item);
this.#prepareTodoStopGuardForAutomaticTurn(continuesCurrentWorkChain);
const promptId =
this.config.getSessionId() + '########cron' + Date.now();
let cronHadError = false;
Expand All @@ -5153,6 +5172,15 @@ export class Session implements SessionContext {
try {
await this.assertCanStartTurn();
if (ac.signal.aborted) return;
this.config.startAutomaticActiveTodoWorkChain(
promptId,
continuesCurrentWorkChain
? this.activeTodoWorkChainPromptId
: undefined,
);
if (continuesCurrentWorkChain) {
this.activeTodoWorkChainPromptId = promptId;
}
// A `<<loop.md>>` / `<<loop.md-dynamic>>` sentinel is expanded at
// fire time into the loop.md task block — full on the first or a
// changed fire, a short reminder when unchanged. Non-sentinel
Expand Down Expand Up @@ -5455,6 +5483,7 @@ export class Session implements SessionContext {
await this.#buildNextMessageAfterToolRun(
toolRun,
ac.signal,
promptId,
);
nextMessage = nextAfterTools.message;
if (toolRun.loopDetected) {
Expand Down Expand Up @@ -5773,14 +5802,23 @@ export class Session implements SessionContext {
async () => {
const ac = new AbortController();
this.notificationAbortController = ac;
this.#prepareTodoStopGuardForAutomaticTurn(
this.#notificationContinuesTodoStopGuardWorkChain(item),
);
const continuesCurrentWorkChain =
this.#notificationContinuesTodoStopGuardWorkChain(item);
this.#prepareTodoStopGuardForAutomaticTurn(continuesCurrentWorkChain);
const promptId =
this.config.getSessionId() + '########notification' + Date.now();
try {
await this.assertCanStartTurn();
if (ac.signal.aborted) return;
this.config.startAutomaticActiveTodoWorkChain(
promptId,
continuesCurrentWorkChain
? this.activeTodoWorkChainPromptId
: undefined,
);
if (continuesCurrentWorkChain) {
this.activeTodoWorkChainPromptId = promptId;
}
await this.#emitBackgroundNotificationDisplay(item);

const notificationParts: Part[] = [{ text: item.modelText }];
Expand Down Expand Up @@ -5953,6 +5991,7 @@ export class Session implements SessionContext {
const nextAfterTools = await this.#buildNextMessageAfterToolRun(
toolRun,
ac.signal,
promptId,
);
nextMessage = nextAfterTools.message;
if (toolRun.loopDetected) {
Expand Down Expand Up @@ -6376,6 +6415,7 @@ export class Session implements SessionContext {
functionCalls: FunctionCall[],
toolLoopState?: DaemonToolLoopState,
): Promise<RunToolResult> {
promptIdContext.enterWith(promptId);
const dedupedFunctionCalls = dedupeToolCallsById(functionCalls);
const generatedCallIdBase = randomUUID();
const executionCallIds = new Map(
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,12 @@ describe('runNonInteractive', () => {
expect(exitCode).toBe(1);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3);
expect(mockCoreExecuteToolCall).not.toHaveBeenCalled();
const drainPromptIds = mockGeminiClient.sendMessageStream.mock.calls
.slice(1)
.map((call) => call[2]);
expect(new Set(drainPromptIds)).toEqual(
new Set(['prompt-id-drain-dup-loop/automatic/2']),
);

const duplicateParts = mockGeminiClient.sendMessageStream.mock
.calls[2][0] as Part[];
Expand Down Expand Up @@ -3188,7 +3194,7 @@ describe('runNonInteractive', () => {
2,
[{ text: notificationXml }],
expect.any(AbortSignal),
'prompt-monitor',
'prompt-monitor/automatic/2',
{
type: SendMessageType.Notification,
modelOverride: undefined,
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,7 @@ export async function runNonInteractive(
};
};

let currentPromptId = prompt_id;
while (true) {
// Drain pending teammate messages into the conversation.
// sendMessageStream only reads currentMessages[0].parts,
Expand Down Expand Up @@ -1678,13 +1679,16 @@ export async function runNonInteractive(
} else {
sendType = SendMessageType.ToolResult;
}
if (isTeammateTurn) {
currentPromptId = `${prompt_id}/teammate/${turnCount}`;
}

const toolCallRequests: ToolCallRequestInfo[] = [];
const apiStartTime = Date.now();
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
currentPromptId,
{
type: sendType,
modelOverride,
Expand Down Expand Up @@ -1926,14 +1930,15 @@ export async function runNonInteractive(
];
let itemIsFirstTurn = true;
let itemModelOverride: string | undefined;
const itemPromptId = `${prompt_id}/automatic/${turnCount}`;

while (true) {
const itemToolCallRequests: ToolCallRequestInfo[] = [];
const itemApiStartTime = Date.now();
const itemStream = geminiClient.sendMessageStream(
itemMessages[0]?.parts || [],
abortController.signal,
prompt_id,
itemPromptId,
{
type: itemIsFirstTurn
? item.sendMessageType
Expand Down
Loading
Loading