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
47 changes: 37 additions & 10 deletions docs/design/active-todo-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,26 @@ 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 under a stable work-chain owner. Prompt IDs used by retries and related
automatic turns resolve to that owner, so concurrent notification branches do
not move or overwrite the foreground reminder. Background tasks and loop
wakeups capture the owner when they are created and carry it back with their
automatic turn; unrelated cron and notification turns use an isolated owner
that is removed when the turn ends. Inject the reminder on the first request of
a retry or related automatic turn and after function responses on later tool
turns. Clear it when all todos complete, a new ordinary work chain starts, or
the session changes.
items under a stable work-chain owner. Prompt IDs used by retries, related
automatic turns, and ordinary user turns that arrive while a reminder is still
registered resolve to that owner, so concurrent notification branches do not
move or overwrite the foreground reminder. Background tasks and loop wakeups
capture the owner when they are created and carry it back with their automatic
turn; unrelated cron and notification turns use an isolated owner that is
removed when the turn ends. Inject the reminder on the first request of a retry
or related automatic turn and after function responses on later tool turns.
Clear it when all todos complete, when an ordinary turn starts with no reminder
registered, or when the session changes.

A registered reminder is the signal that the plan still has unfinished items,
because `todo_write` deletes it once the list completes. An ordinary user turn
therefore continues the chain instead of discarding the context of work that is
still running: the turn that asks how the work is going is the turn that needs
the plan. The accepted cost is that an abandoned plan keeps resurfacing until a
later `todo_write` completes or clears it, while a genuinely new task replaces
the plan on its first write. Both frontends apply this, and in ACP the
todo-stop-guard lineage reset stays keyed to the retry/continue flag alone, so
carrying a plan never widens the guard's trust (#10953).

Every injected copy is recorded permanently in chat history, so per-turn
injection would grow the live context linearly with tool turns. Tool-turn
Expand All @@ -28,6 +39,18 @@ turn-start injections always fire and reset that cadence. The payload is a
compact `- [status] content` line list capped at 800 characters. History stays
append-only, so provider prefix caching is unaffected.

A tool-turn count is a poor proxy for elapsed work when the turn is a delegated
run: a parent blocked on one foreground subagent earns a single tool turn for
the whole execution, so the cadence on its own leaves the plan stale for as
long as the subagent ran — 55 minutes in the report that motivated this rule
(#10953). A tool-result batch that carries a top-level Agent result therefore
forces the reminder due at that boundary, on both frontends, instead of waiting
out the cadence. Forcing stays bounded by registration: with no reminder
registered there is nothing to inject, so a delegation outside an active plan
costs nothing. This re-times delivery of the existing reminder only. It does
not derive plan state from the execution, which remains the model's job through
`todo_write`, and the Agent tool's optional `todo_id` stays observational.

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.
Expand All @@ -38,6 +61,10 @@ change instead preserves task context before that decision.
- 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.
- An ordinary new prompt retains the reminder while items are unfinished and
clears stale state when none is registered; retry/continue always retains it.
Both frontends behave the same.
- A tool-result batch carrying a top-level Agent result forces the reminder due
even though the turn budget is not filled; a batch without one stays budgeted.
- Independent automatic turns are isolated; related automatic turns inherit.
- Terminal automatic turns release their temporary ownership state.
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ describe('Session review-worktree lease sweep', () => {
getModel: vi.fn().mockReturnValue('qwen3'),
getSessionId: vi.fn().mockReturnValue(SESSION_ID),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoWorkChainOwner: vi.fn((promptId: string) => promptId),
setActiveTodoReminder: vi.fn(),
startActiveTodoWorkChain: vi.fn(),
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,7 @@ describe('Session', () => {
isProvisionalWorkspace: vi.fn().mockReturnValue(false),
setLiveAppendSystemPrompt: vi.fn(),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoReminder: vi.fn().mockReturnValue(undefined),
// The restore-ask_user_question prompt path is gated on this flag;
// the restore describe block overrides to true.
getRestoreAskUserQuestion: vi.fn().mockReturnValue(false),
Expand Down Expand Up @@ -2833,6 +2834,99 @@ describe('Session', () => {
);
});

it('continues the todo work chain on an ordinary prompt while a reminder is registered', async () => {
mockChat.sendMessageStream = vi
.fn()
.mockImplementation(async () => createEmptyStream());
// A registered reminder means the plan still has unfinished items
// (todo_write deletes it on completion).
vi.mocked(mockConfig.getActiveTodoReminder).mockReturnValue(
'<system-reminder>unfinished todo: delegated node</system-reminder>',
);

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

// The first prompt has no previous chain to continue.
expect(mockConfig.startActiveTodoWorkChain).toHaveBeenCalledWith(
'test-session-id########1',
undefined,
);

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'how is progress going?' }],
});

// The follow-up turn must continue the previous chain instead of
// discarding the plan context it asks about (#10953).
expect(mockConfig.startActiveTodoWorkChain).toHaveBeenLastCalledWith(
'test-session-id########2',
'test-session-id########1',
);
});

it('forces the active todo reminder due when an Agent tool result returns', async () => {
const reminder =
'<system-reminder>unfinished todo: follow up on the delegated node</system-reminder>';
// Mimic the real budget: nothing is due under the ordinary cadence
// (the delegation consumed the only tool turn), only forcing delivers.
vi.mocked(mockConfig.takeActiveTodoReminder).mockImplementation(
(_promptId: string, force = false) => (force ? reminder : undefined),
);
const execute = vi.fn().mockResolvedValue({
llmContent: 'agent done',
returnDisplay: 'agent done',
});
mockToolRegistry.getTool.mockReturnValue({
name: 'agent',
kind: core.Kind.Execute,
Comment on lines +2883 to +2885

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: This force test only exercises the canonical 'agent' name (both the registry mock and the streamed functionCalls), but Session.ts:7927 calls canonicalToolName() specifically to also catch the legacy task alias — and nothing on the ACP side witnesses that alias (the twin TUI test pins both names via it.each(['agent', 'task'])). Dropping canonicalToolName() at Session.ts:7927 and comparing the raw name leaves the entire ACP suite green, while tool-result batches whose functionResponse carries the legacy name task — still supported per ToolNamesMigration — silently stop forcing the reminder due, re-freezing exactly the delegation-heavy sessions of #10953 on the ACP frontend. Parameterize the test like the core one: it.each(['agent', 'task'])('forces the active todo reminder due when a %s tool result returns', ...), feeding the name into both mockToolRegistry.getTool.mockReturnValue({ name: ... }) and the streamed chunk. The alias the parameterized case must use is task: ToolNames.AGENT in ToolNamesMigration (packages/core/src/tools/tool-names.ts). Acceptance: the 'task' case asserting takeActiveTodoReminder is called with (promptId, true) — removing canonicalToolName at Session.ts:7927 must make it fail.

Witness:

[probe] MUTANT (canonicalToolName dropped at the Session.ts force path):
existing ACP force test still passed (1/1) — mutation survives.
MUTANT + it.each(['agent','task']) fix: 'agent' passed, 'task' FAILED —
AssertionError: expected [] to include '<system-reminder>unfinished todo: fol…'
INTACT code restored: both cases green (2/2).
中文说明

该强制注入测试只覆盖了规范名 'agent'(registry mock 与流式 functionCalls 都是),但 Session.ts:7927 特意调用 canonicalToolName() 以同时捕获旧名 task 别名——而 ACP 侧没有任何测试见证该别名(TUI 的对应测试通过 it.each(['agent', 'task']) 钉住了两个名字)。若在 Session.ts:7927 去掉 canonicalToolName() 改为直接比较原始名字,整个 ACP 测试套件仍然全绿,而 functionResponse 携带旧名 task 的工具结果批次(ToolNamesMigration 仍然支持该名字)将不再强制提醒到期,在 ACP 前端重新冻结 #10953 所针对的委派密集会话。建议像 core 一样参数化该测试:it.each(['agent', 'task'])('forces the active todo reminder due when a %s tool result returns', ...),并将该名字同时传入 mockToolRegistry.getTool.mockReturnValue({ name: ... }) 与流式数据块。参数化用例必须使用的别名是 ToolNamesMigration(packages/core/src/tools/tool-names.ts)中的 task: ToolNames.AGENT。验收标准:'task' 用例断言 takeActiveTodoReminder(promptId, true) 被调用——移除 Session.ts:7927 的 canonicalToolName 后该断言必须失败。

— qwen3.8-max via Qwen Code /review (v0.23.0)

displayName: 'Agent',
description: 'Delegates work to a subagent',
build: vi.fn().mockReturnValue({
params: {},
execute,
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('Agent'),
toolLocations: vi.fn().mockReturnValue([]),
}),
canUpdateOutput: false,
isOutputMarkdown: true,
});
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [{ id: 'call-agent-1', name: 'agent', args: {} }],
},
},
]),
)
.mockResolvedValueOnce(createEmptyStream());

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

expect(execute).toHaveBeenCalledTimes(1);
expect(mockConfig.takeActiveTodoReminder).toHaveBeenCalledWith(
'test-session-id########1',
true,
);
const toolResultCall = vi
.mocked(mockChat.sendMessageStream)
.mock.calls.at(-1)?.[1] as { message: Part[] };
expect(textParts(toolResultCall.message)).toContain(reminder);
});

it('includes active Todo context on the first retry request', async () => {
const reminder =
'<system-reminder>unfinished todo: run tests</system-reminder>';
Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5575,9 +5575,19 @@ export class Session implements SessionContext {
if (!continuesCurrentWorkChain && !this.todoStopGuard.enabled) {
this.#resetTodoStopGuardBackgroundLineage();
}
// A registered reminder means the previous chain's plan still
// has unfinished items (todo_write deletes it on completion):
// continue that chain instead of discarding its context with the
// very turn that may be asking about it (#10953).
const continuesTodoWorkChain =
Comment on lines +5580 to +5582

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-5: This behaviour change leaves docs/design/active-todo-context.md — the committed design spec for this exact mechanism — describing the old semantics: it says the reminder is cleared when "a new ordinary work chain starts", and its Verification section asserts "An ordinary new prompt clears stale state while retry/continue retains it". After this diff an ordinary prompt RETAINS the chain whenever a reminder is registered (pinned by this PR's own new tests), and the doc's cadence paragraph omits the new forced injection at delegation boundaries. The next engineer debugging reminder lifecycle reads that doc — committed under docs/design/ precisely to be the reference — and investigates the wrong direction, e.g. files this continuation as a leak because the doc says it must clear. Update the design doc: replace the clear-on-ordinary-prompt rule with "an ordinary prompt continues the chain while a reminder is registered (unfinished items) and starts a fresh one otherwise", add the delegation-boundary force to the injection/cadence paragraphs, and fix the Verification bullet to match.

Witness:

witness: not run — no execution capability settles a documentation-vs-code
contradiction; settled by direct comparison of committed text:
docs/design/active-todo-context.md "Clear it when all todos complete, a new
ordinary work chain starts, or the session changes" and "An ordinary new
prompt clears stale state while retry/continue retains it" vs the new tests
pinning continuation on ordinary prompts in both packages.
中文说明

此行为变更使 docs/design/active-todo-context.md——正是该机制的已提交设计文档——仍在描述旧语义:文档说提醒会在"新的普通工作链启动时"被清除,其 Verification 一节断言"普通新 prompt 清除陈旧状态,而 retry/continue 保留"。本 diff 之后,只要提醒已注册,普通 prompt 就会保留工作链(由本 PR 自己的新测试钉住),且文档的节奏段落未提及委派边界的新增强制注入。下一位调试提醒生命周期的工程师阅读该文档(它被提交在 docs/design/ 下,正是作为权威参考),会朝错误方向排查——例如按文档"应当清除"的说法把新的续链行为当作泄漏上报。建议更新设计文档:将"普通 prompt 清除"规则改为"普通 prompt 在提醒已注册(仍有未完成项)时延续工作链,否则开启新链",在注入/节奏段落中补充委派边界强制注入,并同步修正 Verification 条目。

— qwen3.8-max via Qwen Code /review (v0.23.0)

continuesCurrentWorkChain ||
(this.activeTodoWorkChainPromptId !== undefined &&
this.config.getActiveTodoReminder(
this.activeTodoWorkChainPromptId,
) !== undefined);
this.config.startActiveTodoWorkChain(
promptId,
continuesCurrentWorkChain
continuesTodoWorkChain
? this.activeTodoWorkChainPromptId
: undefined,
);
Expand Down Expand Up @@ -7908,7 +7918,18 @@ export class Session implements SessionContext {
if (hadMidTurnUserInput) {
this.todoStopGuard.acceptMidTurnUserInput();
}
const activeTodoReminder = this.config.takeActiveTodoReminder(promptId);
// A top-level Agent tool result means a delegated execution just
// returned (#10953): real work advanced while the parent earned a
// single tool turn, so the turn budget cannot come due on its own.
// Force the reminder exactly where the progress information arrives.
const carriesAgentToolResult = toolRun.parts.some(
(part) =>
canonicalToolName(part.functionResponse?.name ?? '') ===
ToolNames.AGENT,
);
Comment on lines +7925 to +7929

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-8: On an ACP user turn with a registered reminder, the reminder is now injected twice into permanent history: the pre-existing turn-start force take (Session.ts:5698) resets the counter to 0 but does not delete the reminder, and this new agent-result force takes it again when the delegation returns — an identical second copy in the same turn. Pre-PR the second take was cadence-gated (elapsed = 1 < 3 → undefined), so such a turn stored one copy. This exceeds the PR's own Risk-section accounting ("at most one reminder injection per completed top-level delegation") and the cadence's documented purpose of bounding permanent history growth; core Retry/Cron/Notification/Teammate turns that delegate have the same double shape (client.ts:3842 + 3932). Note the new Session.test.ts force test already exhibits this: the first send receives the reminder via the turn-start force take, yet the test only asserts the last send contains it. Suppress the agent-result force when this turn already force-injected the reminder: capture whether the turn-start take returned a reminder and pass that into #buildNextMessageAfterToolRun so it only forces when it did not; mirror in client.ts for the Retry/Cron/Notification/Teammate turn-start branch. Dedup cannot key on the cadence counter: both the turn-start force take and setActiveTodoReminder reset turns to 0 (turns.set(owner, 0), ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3, config.ts:292); and the agent-result force must still fire for reminders registered mid-turn via todo_write — the property pinned by this PR's own force tests. Acceptance: with the reminder registered at turn start, assert the reminder text appears exactly once across the user-prompt send and the tool-result send combined; removing the dedup condition makes it appear twice → red.

Witness:

[probe] Intact PR code: PROBE_R1_8 copies_per_send=[1,1] total=2 sends=2
Agent-result force reverted (pre-PR cadence):
PROBE_R1_8 copies_per_send=[1,0] total=1 sends=2
中文说明

在提醒已注册的 ACP 用户轮中,提醒现在会被注入两次进永久历史:既有的轮首强制取用(Session.ts:5698)把计数器重置为 0 但并不删除提醒,而新增的 Agent 结果强制取用在委派返回时再次取用——同一轮内向历史追加一份完全相同的副本。修复前,第二次取用受节奏限制(elapsed = 1 < 3 → undefined),因此这类轮只存一份。这超出了本 PR Risk 一节自己的核算("每次完成的顶层委派至多一次提醒注入"),也超出了节奏机制文档中"约束永久历史增长"的设计目的;core 侧会委派的 Retry/Cron/Notification/Teammate 轮同样是双份形态(client.ts:3842 + 3932)。注意 Session.test.ts 的新增强制注入测试已经暴露了这一点:第一次发送经由轮首强制取用收到了提醒,而测试只断言最后一次发送包含它。建议:当本轮已经强制注入过提醒时,抑制 Agent 结果强制注入——记录轮首取用是否返回了提醒,并将其传入 #buildNextMessageAfterToolRun,仅在未注入过时强制;并在 client.ts 的 Retry/Cron/Notification/Teammate 轮首分支做镜像处理。去重不能以节奏计数器为键:轮首强制取用与 setActiveTodoReminder 都会把 turns 重置为 0(turns.set(owner, 0)ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3,config.ts:292);且 Agent 结果强制注入必须对轮中经 todo_write 注册的提醒仍然生效——这是本 PR 自己的强制注入测试钉住的性质。验收标准:在轮首已注册提醒的情况下,断言提醒文本在"用户 prompt 发送"与"工具结果发送"合计仅出现一次;移除去重条件后出现两次 → 变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

const activeTodoReminder = carriesAgentToolResult
? this.config.takeActiveTodoReminder(promptId, true)
: this.config.takeActiveTodoReminder(promptId);
if (abortSignal.aborted) {
return {
message: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe('Session.pendingWorktreeNotice', () => {
getModel: vi.fn().mockReturnValue('qwen3'),
getSessionId: vi.fn().mockReturnValue(SESSION_ID),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoWorkChainOwner: vi.fn((promptId: string) => promptId),
setActiveTodoReminder: vi.fn(),
startActiveTodoWorkChain: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core/client-goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ function setupGoalClient() {
startAutomaticActiveTodoWorkChain: vi.fn(),
endAutomaticActiveTodoWorkChain: vi.fn(),
takeActiveTodoReminder: vi.fn(() => undefined),
getActiveTodoReminder: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => undefined),
hasHooksForEvent: vi.fn(() => false),
getStopHookBlockingCap: vi.fn(() => 8),
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ describe('Gemini Client (client.ts)', () => {
getFullContext: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoWorkChainOwner: vi.fn((promptId: string) => promptId),
startActiveTodoWorkChain: vi.fn(),
startAutomaticActiveTodoWorkChain: vi.fn(),
Expand Down Expand Up @@ -2195,8 +2196,11 @@ describe('Gemini Client (client.ts)', () => {

await runTurn(SendMessageType.UserQuery);

// No reminder is registered here (getActiveTodoReminder returns
// undefined), so the ordinary user turn still starts a fresh chain.
expect(mockConfig.startActiveTodoWorkChain).toHaveBeenCalledWith(
'prompt-userQuery',
undefined,
);

await runTurn(SendMessageType.Cron);
Expand All @@ -2217,6 +2221,101 @@ describe('Gemini Client (client.ts)', () => {
);
});

it.each(['agent', 'task'])(
'forces the active todo reminder due when a %s tool result returns',
async (agentToolName) => {
const reminder =
'<system-reminder>unfinished todo: follow up on the delegated node</system-reminder>';
vi.mocked(mockConfig.takeActiveTodoReminder).mockReturnValue(reminder);
mockTurnRunFn.mockReturnValue(
(async function* () {
yield { type: LlmEventType.Content, value: 'response' };
})(),
);

const stream = client.sendMessageStream(
[
{
functionResponse: {
name: agentToolName,
response: { ok: true },
},
},
],
new AbortController().signal,
'prompt-agent-result',
{ type: SendMessageType.ToolResult },
);
for await (const _ of stream) {
// drain
}

// A delegated execution returning is the progress signal the
// turn budget cannot see (#10953): the reminder must be due now,
// not after three parent tool turns that never come.
expect(mockConfig.takeActiveTodoReminder).toHaveBeenCalledWith(
'prompt-agent-result',
true,
);
const request = mockTurnRunFn.mock.lastCall?.[1] as unknown[];
expect(request).toContain(reminder);
},
);

it('keeps the turn budget for tool results without an Agent execution', async () => {
vi.mocked(mockConfig.takeActiveTodoReminder).mockReturnValue(undefined);
mockTurnRunFn.mockReturnValue(
(async function* () {
yield { type: LlmEventType.Content, value: 'response' };
})(),
);

const stream = client.sendMessageStream(
[{ functionResponse: { name: 'shell', response: { ok: true } } }],
new AbortController().signal,
'prompt-plain-result',
{ type: SendMessageType.ToolResult },
);
for await (const _ of stream) {
// drain
}

expect(mockConfig.takeActiveTodoReminder).toHaveBeenCalledWith(
'prompt-plain-result',
);
});

it('continues the todo work chain on a user turn while a reminder is registered', async () => {
mockTurnRunFn.mockReturnValue(
(async function* () {
yield { type: LlmEventType.Content, value: 'response' };
})(),
);
await runTurn(SendMessageType.UserQuery);
// A registered reminder means the plan still has unfinished items
// (todo_write deletes it on completion).
vi.mocked(mockConfig.getActiveTodoReminder).mockReturnValue(
'<system-reminder>unfinished todo: delegated node</system-reminder>',
);

const stream = client.sendMessageStream(
[{ text: 'how is progress going?' }],
new AbortController().signal,
'prompt-user-followup',
{ type: SendMessageType.UserQuery },
);
for await (const _ of stream) {
// drain
}

// The follow-up turn must continue the previous chain instead of
// discarding the plan context it asks about (#10953).
expect(mockConfig.startActiveTodoWorkChain).toHaveBeenLastCalledWith(
'prompt-user-followup',
'prompt-userQuery',
);
});

it('includes active Todo context on the first retry request', async () => {
const reminder =
'<system-reminder>unfinished todo: run tests</system-reminder>';
Expand Down
Loading
Loading