fix(core): preserve active Todo context across tool turns (#7919)

* fix(core): preserve active Todo context across tool turns

* test(cli): update automatic turn prompt expectation

* fix(core): preserve Todo ownership across automatic turns

* fix(core): preserve Todo ownership at prompt boundaries

* test(todo): cover automatic reminder boundaries

* fix(core): throttle active Todo reminder re-injection to bound history growth

Every injected reminder copy lands permanently in chat history, so per-turn
injection grew the live context linearly with tool turns. Tool-turn injection
now re-issues the reminder only every third tool turn since the state was
last presented; turn-start injections always fire and reset the cadence. The
payload becomes a compact status/content line list capped at 800 characters.
History stays append-only, so provider prefix caching is unaffected.

Also: cover the new-ordinary-prompt-clears-stale-reminders invariant on the
real Config, add TUI coverage for the work-chain notification batch split,
cover todoWorkChainId continuation forwarding, and document the deliberate
enterWith binding in the daemon tool runner.

* fix(core): keep todo reminder before drained input

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
易良 2026-07-30 09:33:40 +08:00 committed by GitHub
parent eb28b3038e
commit 2abfa3d54e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1260 additions and 53 deletions

View file

@ -0,0 +1,43 @@
# 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 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.
Every injected copy is recorded permanently in chat history, so per-turn
injection would grow the live context linearly with tool turns. Tool-turn
injection therefore re-issues the reminder only every third tool turn since
the last time the state was presented (the `todo_write` result itself counts);
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.
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.
- Terminal automatic turns release their temporary ownership state.

View file

@ -102,6 +102,12 @@ describe('Session review-worktree lease sweep', () => {
switchModel: vi.fn(),
getModel: vi.fn().mockReturnValue('qwen3'),
getSessionId: vi.fn().mockReturnValue(SESSION_ID),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoWorkChainOwner: vi.fn((promptId: string) => promptId),
setActiveTodoReminder: vi.fn(),
startActiveTodoWorkChain: vi.fn(),
startAutomaticActiveTodoWorkChain: vi.fn(),
endAutomaticActiveTodoWorkChain: vi.fn(),
assertCanStartTurn: vi.fn().mockResolvedValue(undefined),
getWorkingDir: vi.fn().mockReturnValue('/tmp'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),

View file

@ -569,6 +569,12 @@ describe('Session', () => {
switchModel: switchModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
setActiveTodoReminder: vi.fn(),
startActiveTodoWorkChain: vi.fn(),
startAutomaticActiveTodoWorkChain: vi.fn(),
endAutomaticActiveTodoWorkChain: vi.fn(),
getActiveTodoWorkChainOwner: vi.fn((promptId: string) => promptId),
assertCanStartTurn: vi.fn().mockResolvedValue(undefined),
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
getProjectRoot: vi.fn().mockReturnValue('/repo'),
@ -765,6 +771,152 @@ 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('includes active Todo context on the first retry request', async () => {
const reminder =
'<system-reminder>unfinished todo: run tests</system-reminder>';
vi.mocked(mockConfig.takeActiveTodoReminder).mockReturnValue(reminder);
mockChat.sendMessageStream = vi
.fn()
.mockImplementation(async () => createEmptyStream());
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'start work' }],
});
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'start work' }],
retry: true,
} as PromptRequest);
const retryCall = vi
.mocked(mockChat.sendMessageStream)
.mock.calls.at(-1)?.[1] as {
message: Part[];
};
expect(textParts(retryCall.message)).toContain(reminder);
});
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 reminder =
'<system-reminder>unfinished todo: wait for agent</system-reminder>';
vi.mocked(mockConfig.takeActiveTodoReminder).mockReturnValue(reminder);
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;
todoWorkChainId?: string;
},
) => void;
callback('Background task completed.', '<task-notification/>', {
agentId: 'related-agent',
status: 'completed',
todoWorkChainId: 'test-session-id########1',
});
await vi.waitFor(() =>
expect(mockConfig.startAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
expect.stringContaining('########notification'),
'test-session-id########1',
),
);
await vi.waitFor(() =>
expect(mockConfig.endAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
expect.stringContaining('########notification'),
),
);
const notificationCall = vi
.mocked(mockChat.sendMessageStream)
.mock.calls.at(-1)?.[1] as { message: Part[] };
expect(textParts(notificationCall.message)).toContain(reminder);
});
it('does not infer Todo ownership from Todo Stop Guard lineage', 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('guard-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: 'guard-related-agent',
status: 'completed',
});
await vi.waitFor(() =>
expect(mockConfig.startAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
expect.stringContaining('########notification'),
undefined,
),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'start work' }],
retry: true,
} as PromptRequest);
expect(mockConfig.startActiveTodoWorkChain).toHaveBeenLastCalledWith(
'test-session-id########2',
'test-session-id########1',
);
});
it('holds the close gate until active turns settle', async () => {
let resolveTurn!: () => void;
const turnCompletion = new Promise<void>((resolve) => {
@ -6062,9 +6214,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.takeActiveTodoReminder).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',
@ -6116,9 +6285,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 ');
@ -10478,6 +10657,7 @@ describe('Session', () => {
expect(mockGeminiClient.tryCompressChat).not.toHaveBeenCalled();
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
expect(mockConfig.startActiveTodoWorkChain).not.toHaveBeenCalled();
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {

View file

@ -144,6 +144,7 @@ import {
setGoalTerminalObserver,
sessionIdContext,
promptIdContext,
todoWorkChainContext,
dedupeToolCallsById,
getProviderToolCallId,
parsePositiveIntegerEnv,
@ -820,6 +821,7 @@ interface BackgroundNotificationQueueItem {
kind: 'agent' | 'monitor' | 'shell';
continuesTodoStopGuardWorkChain: boolean;
toolUseId?: string;
todoWorkChainId?: string;
}
/** The slice of `CronJob` a fire delivers to this session. Structural, not the
@ -834,6 +836,7 @@ interface CronFire {
* identifies this fire's entry in `runs[]`. */
lastFiredAt?: number;
delivery?: CronTaskDelivery;
todoWorkChainId?: string;
}
interface CronQueueItem {
@ -842,6 +845,7 @@ interface CronQueueItem {
taskId?: string;
firedAt?: number;
delivery?: CronTaskDelivery;
todoWorkChainId?: string;
}
interface PromptChannelDelivery {
@ -1237,6 +1241,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
@ -1346,14 +1351,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);
@ -2635,6 +2634,12 @@ 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;
// 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
@ -2854,6 +2859,17 @@ export class Session implements SessionContext {
}
}
if (!continuesCurrentWorkChain && !this.todoStopGuard.enabled) {
this.#resetTodoStopGuardBackgroundLineage();
}
this.config.startActiveTodoWorkChain(
promptId,
continuesCurrentWorkChain
? this.activeTodoWorkChainPromptId
: undefined,
);
this.activeTodoWorkChainPromptId = promptId;
// Snapshot file state before this turn (mirrors the makeSnapshot
// block in GeminiClient.sendMessageStream). Placed after
// slash-command and hook early-returns so locally handled commands
@ -2932,6 +2948,19 @@ export class Session implements SessionContext {
this.pendingRecoveredAgentsNotice = null;
}
const activeTodoReminder = this.config.takeActiveTodoReminder(
promptId,
true,
);
if (
activeTodoReminder &&
!parts.some((part) => part.text === activeTodoReminder)
) {
parts = insertAfterFunctionResponses(parts, [
{ text: activeTodoReminder },
]);
}
let nextMessage: Content | null = { role: 'user', parts };
let turnCount = 0;
const toolLoopState = createDaemonToolLoopState();
@ -3192,6 +3221,7 @@ export class Session implements SessionContext {
await this.#buildNextMessageAfterToolRun(
toolRun,
pendingSend.signal,
promptId,
onFullTurnModel,
);
nextMessage = nextAfterTools.message;
@ -4136,6 +4166,7 @@ export class Session implements SessionContext {
const nextAfterTools = await this.#buildNextMessageAfterToolRun(
toolRun,
pendingSend.signal,
toolPromptId,
options.onFullTurnModel,
);
nextMessage = nextAfterTools.message;
@ -4539,6 +4570,7 @@ export class Session implements SessionContext {
async #buildNextMessageAfterToolRun(
toolRun: RunToolResult,
abortSignal: AbortSignal,
promptId: string,
onFullTurnModel?: (model: string) => boolean,
): Promise<NextMessageAfterToolRun> {
if (toolRun.loopDetected) {
@ -4559,7 +4591,12 @@ export class Session implements SessionContext {
if (hadMidTurnUserInput) {
this.todoStopGuard.acceptMidTurnUserInput();
}
const parts = [...toolRun.parts, ...drained.parts];
const activeTodoReminder = this.config.takeActiveTodoReminder(promptId);
const parts = [
...toolRun.parts,
...(activeTodoReminder ? [{ text: activeTodoReminder }] : []),
...drained.parts,
];
return {
message: { role: 'user', parts },
hadMidTurnUserInput,
@ -4948,6 +4985,9 @@ export class Session implements SessionContext {
...(job.id ? { taskId: job.id } : {}),
...(job.lastFiredAt !== undefined ? { firedAt: job.lastFiredAt } : {}),
...(job.delivery ? { delivery: job.delivery } : {}),
...(job.todoWorkChainId
? { todoWorkChainId: job.todoWorkChainId }
: {}),
});
void this.#drainCronQueue();
});
@ -5140,9 +5180,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;
@ -5162,6 +5202,10 @@ export class Session implements SessionContext {
try {
await this.assertCanStartTurn();
if (ac.signal.aborted) return;
this.config.startAutomaticActiveTodoWorkChain(
promptId,
item.todoWorkChainId,
);
// 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
@ -5294,9 +5338,17 @@ export class Session implements SessionContext {
// Prepend session-level system reminders (same rationale as the
// user-query path in #executePrompt).
const cronReminders = await this.#buildInitialSystemReminders();
const activeTodoReminder = this.config.takeActiveTodoReminder(
promptId,
true,
);
let nextMessage: Content | null = {
role: 'user',
parts: [...cronReminders, { text: modelText }],
parts: [
...cronReminders,
...(activeTodoReminder ? [{ text: activeTodoReminder }] : []),
{ text: modelText },
],
};
const toolLoopState = createDaemonToolLoopState();
@ -5464,6 +5516,7 @@ export class Session implements SessionContext {
await this.#buildNextMessageAfterToolRun(
toolRun,
ac.signal,
promptId,
);
nextMessage = nextAfterTools.message;
if (toolRun.loopDetected) {
@ -5504,6 +5557,7 @@ export class Session implements SessionContext {
`[${item.source} error] ${msg}`,
);
} finally {
this.config.endAutomaticActiveTodoWorkChain(promptId);
if (this.cronAbortController === ac) {
this.cronAbortController = null;
}
@ -5572,6 +5626,7 @@ export class Session implements SessionContext {
continuesTodoStopGuardWorkChain:
this.#agentContinuesTodoStopGuardWorkChain(meta.agentId),
toolUseId: meta.toolUseId,
todoWorkChainId: meta.todoWorkChainId,
});
},
);
@ -5594,6 +5649,7 @@ export class Session implements SessionContext {
meta.ownerAgentId,
),
toolUseId: meta.toolUseId,
todoWorkChainId: meta.todoWorkChainId,
});
});
@ -5607,6 +5663,7 @@ export class Session implements SessionContext {
kind: 'shell',
continuesTodoStopGuardWorkChain:
!this.todoStopGuardBackgroundBaseline.shells.has(meta.shellId),
todoWorkChainId: meta.todoWorkChainId,
});
});
@ -5782,14 +5839,18 @@ 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,
item.todoWorkChainId,
);
await this.#emitBackgroundNotificationDisplay(item);
const notificationParts: Part[] = [{ text: item.modelText }];
@ -5804,9 +5865,17 @@ export class Session implements SessionContext {
const notificationReminders =
await this.#buildInitialSystemReminders();
const activeTodoReminder = this.config.takeActiveTodoReminder(
promptId,
true,
);
let nextMessage: Content | null = {
role: 'user',
parts: [...notificationReminders, ...notificationParts],
parts: [
...notificationReminders,
...(activeTodoReminder ? [{ text: activeTodoReminder }] : []),
...notificationParts,
],
};
const toolLoopState = createDaemonToolLoopState();
@ -5962,6 +6031,7 @@ export class Session implements SessionContext {
const nextAfterTools = await this.#buildNextMessageAfterToolRun(
toolRun,
ac.signal,
promptId,
);
nextMessage = nextAfterTools.message;
if (toolRun.loopDetected) {
@ -6016,6 +6086,7 @@ export class Session implements SessionContext {
await this.#emitBackgroundNotificationEndTurn('end_turn');
}
} finally {
this.config.endAutomaticActiveTodoWorkChain(promptId);
if (this.notificationAbortController === ac) {
this.notificationAbortController = null;
}
@ -6386,6 +6457,17 @@ export class Session implements SessionContext {
toolLoopState?: DaemonToolLoopState,
onFullTurnModel?: (model: string) => boolean,
): Promise<RunToolResult> {
// The daemon executes tools directly rather than through
// CoreToolScheduler, so the ALS bindings the scheduler would provide must
// happen here. `enterWith` (not `run`) is deliberate: background
// task/shell/monitor registration can occur in async continuations of
// this batch after runToolCalls resolves, and those must still observe
// this prompt's work-chain owner. The turn loop rebinds on the next
// runToolCalls, and turn starts re-enter via #executePrompt.
promptIdContext.enterWith(promptId);
todoWorkChainContext.enterWith(
this.config.getActiveTodoWorkChainOwner(promptId),
);
const dedupedFunctionCalls = dedupeToolCallsById(functionCalls);
const generatedCallIdBase = randomUUID();
const executionCallIds = new Map(

View file

@ -108,6 +108,12 @@ describe('Session.pendingWorktreeNotice', () => {
switchModel: vi.fn(),
getModel: vi.fn().mockReturnValue('qwen3'),
getSessionId: vi.fn().mockReturnValue(SESSION_ID),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoWorkChainOwner: vi.fn((promptId: string) => promptId),
setActiveTodoReminder: vi.fn(),
startActiveTodoWorkChain: vi.fn(),
startAutomaticActiveTodoWorkChain: vi.fn(),
endAutomaticActiveTodoWorkChain: vi.fn(),
assertCanStartTurn: vi.fn().mockResolvedValue(undefined),
getWorkingDir: vi.fn().mockReturnValue('/tmp'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),

View file

@ -1547,7 +1547,7 @@ describe('runNonInteractive', () => {
4,
[{ text: 'drain image a' }, { text: 'drain image b' }],
expect.any(AbortSignal),
'prompt-drain',
'prompt-drain/automatic/3',
{ type: SendMessageType.ToolResult, modelOverride: first },
);
});
@ -2275,6 +2275,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[];
@ -3468,7 +3474,7 @@ describe('runNonInteractive', () => {
2,
[{ text: notificationXml }],
expect.any(AbortSignal),
'prompt-monitor',
'prompt-monitor/automatic/2',
{
type: SendMessageType.Notification,
modelOverride: undefined,
@ -3506,6 +3512,111 @@ describe('runNonInteractive', () => {
});
});
it('keeps notifications from different Todo work chains in separate batches', async () => {
setupMetricsMock();
const firstNotificationXml =
'<task-notification>\n' +
'<task-id>mon_1</task-id>\n' +
'<kind>monitor</kind>\n' +
'<status>running</status>\n' +
'<summary>Monitor emitted event #1.</summary>\n' +
'<result>ready</result>\n' +
'</task-notification>';
const secondNotificationXml =
'<task-notification>\n' +
'<task-id>mon_2</task-id>\n' +
'<kind>monitor</kind>\n' +
'<status>running</status>\n' +
'<summary>Monitor emitted event #2.</summary>\n' +
'<result>also ready</result>\n' +
'</task-notification>';
mockMonitorRegistry.setNotificationCallback.mockImplementation((cb) => {
if (!cb) {
return;
}
cb('Monitor "logs" event #1: ready', firstNotificationXml, {
monitorId: 'mon_1',
toolUseId: 'tool_mon_1',
status: 'running',
eventCount: 1,
todoWorkChainId: 'chain-1',
});
cb('Monitor "build" event #1: ready', secondNotificationXml, {
monitorId: 'mon_2',
toolUseId: 'tool_mon_2',
status: 'running',
eventCount: 1,
todoWorkChainId: 'chain-2',
});
});
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'Started.' },
{
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata: { totalTokenCount: 1 },
},
},
]),
)
.mockReturnValueOnce(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'First notification.' },
{
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata: { totalTokenCount: 1 },
},
},
]),
)
.mockReturnValueOnce(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'Second notification.' },
{
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata: { totalTokenCount: 1 },
},
},
]),
);
await runNonInteractive(
mockConfig,
mockSettings,
'Watch the logs',
'prompt-monitor-work-chains',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3);
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
[{ text: firstNotificationXml }],
expect.any(AbortSignal),
'prompt-monitor-work-chains/automatic/2',
expect.objectContaining({
todoWorkChainId: 'chain-1',
}),
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
3,
[{ text: secondNotificationXml }],
expect.any(AbortSignal),
'prompt-monitor-work-chains/automatic/3',
expect.objectContaining({
todoWorkChainId: 'chain-2',
}),
);
});
it.skip('should emit a single user envelope when userEnvelope is provided', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json');
(mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false);

View file

@ -456,6 +456,7 @@ export async function runNonInteractive(
displayText: string;
modelText: string;
sendMessageType: SendMessageType;
todoWorkChainId?: string;
monitorId?: string;
sdkNotification?: {
task_id: string;
@ -901,6 +902,7 @@ export async function runNonInteractive(
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
todoWorkChainId: meta.todoWorkChainId,
sdkNotification: {
task_id: meta.agentId,
tool_use_id: meta.toolUseId,
@ -946,6 +948,7 @@ export async function runNonInteractive(
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
todoWorkChainId: meta.todoWorkChainId,
monitorId: meta.monitorId,
sdkNotification: {
task_id: meta.monitorId,
@ -1629,6 +1632,7 @@ export async function runNonInteractive(
};
};
let currentPromptId = prompt_id;
while (true) {
// Drain pending teammate messages into the conversation.
// sendMessageStream only reads currentMessages[0].parts,
@ -1682,13 +1686,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,
@ -1898,7 +1905,9 @@ export async function runNonInteractive(
if (splitIdx === 0) {
while (
splitIdx < localQueue.length &&
localQueue[splitIdx]!.sendMessageType === targetType
localQueue[splitIdx]!.sendMessageType === targetType &&
localQueue[splitIdx]!.todoWorkChainId ===
localQueue[0]!.todoWorkChainId
) {
splitIdx++;
}
@ -1917,6 +1926,7 @@ export async function runNonInteractive(
displayText: batch.map((i) => i.displayText).join('; '),
modelText: batch.map((i) => i.modelText).join('\n\n'),
sendMessageType: targetType,
todoWorkChainId: batch[0]?.todoWorkChainId,
};
turnCount++;
@ -1933,6 +1943,7 @@ export async function runNonInteractive(
];
let itemIsFirstTurn = true;
let itemModelOverride: string | undefined;
const itemPromptId = `${prompt_id}/automatic/${turnCount}`;
while (true) {
const itemToolCallRequests: ToolCallRequestInfo[] = [];
@ -1940,7 +1951,7 @@ export async function runNonInteractive(
const itemStream = geminiClient.sendMessageStream(
itemMessages[0]?.parts || [],
abortController.signal,
prompt_id,
itemPromptId,
{
type: itemIsFirstTurn
? item.sendMessageType
@ -1948,6 +1959,7 @@ export async function runNonInteractive(
modelOverride: itemModelOverride,
...(itemIsFirstTurn && {
notificationDisplayText: item.displayText,
todoWorkChainId: item.todoWorkChainId,
}),
},
);
@ -2183,6 +2195,7 @@ export async function runNonInteractive(
displayText: `${job.cronExpr === '@wakeup' ? 'Loop' : 'Cron'}: ${label}`,
modelText: job.prompt,
sendMessageType: SendMessageType.Cron,
todoWorkChainId: job.todoWorkChainId,
});
drainLocalQueue().then(checkCronDone, onDrainError);
});

View file

@ -7159,6 +7159,79 @@ describe('useGeminiStream', () => {
);
});
it('keeps notifications from different Todo work chains in separate turns', async () => {
renderTestHook();
const callback = mockMonitorRegistry.setNotificationCallback.mock
.calls[0][0] as (
displayText: string,
modelText: string,
meta: {
monitorId: string;
status: string;
todoWorkChainId?: string;
},
) => void;
mockSendMessageStream.mockClear();
await act(async () => {
callback(
'Monitor "logs" event #1: ready',
'<task-notification>first</task-notification>',
{
monitorId: 'mon_1',
status: 'completed',
todoWorkChainId: 'chain-1',
},
);
callback(
'Monitor "build" event #1: ready',
'<task-notification>second</task-notification>',
{
monitorId: 'mon_2',
status: 'completed',
todoWorkChainId: 'chain-2',
},
);
});
await waitFor(() =>
expect(mockSendMessageStream).toHaveBeenCalledTimes(1),
);
const firstCall = mockSendMessageStream.mock.calls[0];
expect(JSON.stringify(firstCall[0])).toContain('first');
expect(JSON.stringify(firstCall[0])).not.toContain('second');
expect(firstCall[3]).toMatchObject({
type: SendMessageType.Notification,
todoWorkChainId: 'chain-1',
});
// A further chain-2 event re-triggers the drain; the two queued
// chain-2 items batch into a single turn.
await act(async () => {
callback(
'Monitor "build" event #2: done',
'<task-notification>third</task-notification>',
{
monitorId: 'mon_2',
status: 'completed',
todoWorkChainId: 'chain-2',
},
);
});
await waitFor(() =>
expect(mockSendMessageStream).toHaveBeenCalledTimes(2),
);
const secondCall = mockSendMessageStream.mock.calls[1];
expect(JSON.stringify(secondCall[0])).toContain('second');
expect(JSON.stringify(secondCall[0])).toContain('third');
expect(secondCall[3]).toMatchObject({
type: SendMessageType.Notification,
todoWorkChainId: 'chain-2',
});
});
// Regression for #7156: progress setState calls issued from inside a
// background subagent's AsyncLocalStorage frame can batch with the
// notification trigger into one React commit, so the drain effect

View file

@ -2662,6 +2662,7 @@ export const useGeminiStream = (
prompt_id?: string,
metadata?: {
notificationDisplayText?: string;
todoWorkChainId?: string;
onDelivered?: () => void;
onDeliveryFailed?: () => void;
steerInput?: SteerInput;
@ -2912,6 +2913,7 @@ export const useGeminiStream = (
const sendOptions = {
type: submitType,
notificationDisplayText: metadata?.notificationDisplayText,
todoWorkChainId: metadata?.todoWorkChainId,
modelOverride: modelOverrideRef.current,
steerInput: metadata?.steerInput,
...(submittedPrompt !== undefined ? { submittedPrompt } : {}),
@ -3823,6 +3825,7 @@ export const useGeminiStream = (
modelText: string;
sendMessageType: SendMessageType;
monitor?: { id: string; status: string };
todoWorkChainId?: string;
onDelivered?: () => void;
onDeliveryFailed?: () => void;
}>
@ -3898,6 +3901,7 @@ export const useGeminiStream = (
prompt: string;
cronExpr?: string;
missed?: boolean;
todoWorkChainId?: string;
}) => {
const source = job.cronExpr === '@wakeup' ? 'Loop' : 'Cron';
const autonomousMode = detectAutonomousSentinel(job.prompt);
@ -3913,6 +3917,7 @@ export const useGeminiStream = (
displayText: `${job.missed ? 'Missed' : source}: ${label}`,
modelText,
sendMessageType: SendMessageType.Cron,
todoWorkChainId: job.todoWorkChainId,
onDelivered: () => resolver.markDelivered(),
});
setNotificationTrigger((n) => n + 1);
@ -3922,6 +3927,7 @@ export const useGeminiStream = (
displayText: `${job.missed ? 'Missed' : source}: ${label}`,
modelText,
sendMessageType: SendMessageType.Cron,
todoWorkChainId: job.todoWorkChainId,
});
setNotificationTrigger((n) => n + 1);
},
@ -3941,11 +3947,12 @@ export const useGeminiStream = (
// Register background agent notification callback onto the shared queue.
useEffect(() => {
const registry = config.getBackgroundTaskRegistry();
registry.setNotificationCallback((displayText, modelText) => {
registry.setNotificationCallback((displayText, modelText, meta) => {
notificationQueueRef.current.push({
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
todoWorkChainId: meta?.todoWorkChainId,
});
setNotificationTrigger((n) => n + 1);
});
@ -3957,11 +3964,12 @@ export const useGeminiStream = (
// Register background shell terminal notification callback onto the shared queue.
useEffect(() => {
const registry = config.getBackgroundShellRegistry();
registry.setNotificationCallback((displayText, modelText) => {
registry.setNotificationCallback((displayText, modelText, meta) => {
notificationQueueRef.current.push({
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
todoWorkChainId: meta?.todoWorkChainId,
});
setNotificationTrigger((n) => n + 1);
});
@ -3983,6 +3991,7 @@ export const useGeminiStream = (
modelText,
sendMessageType: SendMessageType.Notification,
monitor: { id: meta.monitorId, status: meta.status },
todoWorkChainId: meta.todoWorkChainId,
});
setNotificationTrigger((n) => n + 1);
});
@ -4040,6 +4049,7 @@ export const useGeminiStream = (
);
submitQuery(item.modelText, item.sendMessageType, undefined, {
notificationDisplayText: item.displayText,
todoWorkChainId: item.todoWorkChainId,
onDelivered: item.onDelivered,
onDeliveryFailed: item.onDeliveryFailed,
});
@ -4050,7 +4060,8 @@ export const useGeminiStream = (
let splitIdx = 0;
while (
splitIdx < queue.length &&
queue[splitIdx]!.sendMessageType === targetType
queue[splitIdx]!.sendMessageType === targetType &&
queue[splitIdx]!.todoWorkChainId === queue[0]!.todoWorkChainId
) {
splitIdx++;
}
@ -4068,6 +4079,7 @@ export const useGeminiStream = (
const combinedDisplayText = batch.map((e) => e.displayText).join('; ');
submitQuery(combinedModelText, targetType, undefined, {
notificationDisplayText: combinedDisplayText,
todoWorkChainId: batch[0]?.todoWorkChainId,
});
});
}

View file

@ -26,6 +26,7 @@ import {
import * as transcript from './agent-transcript.js';
import { AgentEventEmitter, AgentEventType } from './runtime/agent-events.js';
import { ToolConfirmationOutcome } from '../tools/tools.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
function makeApproval(
callId: string,
@ -60,6 +61,15 @@ function makeRegistration(
}
describe('notification emission and agent context (#7156)', () => {
it('captures the Todo work-chain owner at registration', () => {
const registry = new BackgroundTaskRegistry();
const entry = todoWorkChainContext.run('work-chain-1', () =>
registry.register(makeRegistration('bg-owner')),
);
expect(entry.todoWorkChainId).toBe('work-chain-1');
});
// A background agent's terminal transition fires inside its own
// AsyncLocalStorage frame, and ALS context follows every async
// continuation the notification callback starts (React state updates,

View file

@ -24,6 +24,7 @@
import { ToolConfirmationOutcome } from '../tools/tools.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { parsePositiveIntegerEnv } from '../utils/env.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
import { escapeXml } from '../utils/xml.js';
import { patchAgentMeta } from './agent-transcript.js';
import { runOutsideAgentContext } from './runtime/agent-context.js';
@ -385,6 +386,7 @@ export interface NotificationMeta {
status: TaskStatus;
stats?: AgentCompletionStats;
toolUseId?: string;
todoWorkChainId?: string;
}
export type BackgroundNotificationCallback = (
@ -676,6 +678,7 @@ export class BackgroundTaskRegistry {
entry.notified = options.preserveNotificationState
? ((registration as AgentTask).notified ?? false)
: false;
entry.todoWorkChainId ??= todoWorkChainContext.getStore();
entry.pendingMessages = registration.pendingMessages ?? [];
// Resolve the parent's display name at registration time — before the
// parent can evict — so the UI's orphan annotation survives it. Owned
@ -1601,6 +1604,7 @@ export class BackgroundTaskRegistry {
status: entry.status,
stats: entry.stats,
toolUseId: entry.toolUseId,
todoWorkChainId: entry.todoWorkChainId,
};
try {

View file

@ -98,6 +98,8 @@ export interface TaskBase {
outputOffset: number;
/** True once the kind's terminal notification has fired. */
notified: boolean;
/** Todo work chain that created this task, when it was model-launched. */
todoWorkChainId?: string;
/** Unified cancellation handle. */
abortController: AbortController;
}

View file

@ -8959,4 +8959,133 @@ describe('Model Switching and Config Updates', () => {
expect(response.success).toBe(true);
});
});
it('moves only the continued work chain Todo reminder', () => {
const config = Object.create(Config.prototype) as Config;
config.setActiveTodoReminder('prompt-user', 'unfinished user work');
config.setActiveTodoReminder('prompt-cron', 'unfinished cron work');
config.startActiveTodoWorkChain('prompt-retry', 'prompt-user');
expect(config.getActiveTodoReminder('prompt-retry')).toBe(
'unfinished user work',
);
expect(config.getActiveTodoReminder('prompt-user')).toBe(
'unfinished user work',
);
expect(config.getActiveTodoReminder('prompt-cron')).toBeUndefined();
});
it('clears stale Todo reminders when a new ordinary work chain starts', () => {
const config = Object.create(Config.prototype) as Config;
config.startActiveTodoWorkChain('prompt-old');
config.setActiveTodoReminder('prompt-old', 'old work');
config.startActiveTodoWorkChain('prompt-new');
expect(config.getActiveTodoReminder('prompt-new')).toBeUndefined();
expect(config.getActiveTodoReminder('prompt-old')).toBeUndefined();
});
it('re-issues the active Todo reminder only every third tool turn', () => {
const config = Object.create(Config.prototype) as Config;
config.startActiveTodoWorkChain('prompt-user');
config.setActiveTodoReminder('prompt-user', 'unfinished work');
expect(config.takeActiveTodoReminder('prompt-user')).toBeUndefined();
expect(config.takeActiveTodoReminder('prompt-user')).toBeUndefined();
expect(config.takeActiveTodoReminder('prompt-user')).toBe(
'unfinished work',
);
expect(config.takeActiveTodoReminder('prompt-user')).toBeUndefined();
expect(config.takeActiveTodoReminder('prompt-user', true)).toBe(
'unfinished work',
);
expect(config.takeActiveTodoReminder('prompt-user')).toBeUndefined();
expect(config.takeActiveTodoReminder('prompt-user')).toBeUndefined();
expect(config.takeActiveTodoReminder('prompt-user')).toBe(
'unfinished work',
);
config.setActiveTodoReminder('prompt-user', 'updated work');
expect(config.takeActiveTodoReminder('prompt-user')).toBeUndefined();
});
it('moves related automatic work without clearing unrelated reminders', () => {
const config = Object.create(Config.prototype) as Config;
config.startActiveTodoWorkChain('prompt-user');
config.setActiveTodoReminder('prompt-user', 'unfinished user work');
config.startAutomaticActiveTodoWorkChain('prompt-unrelated');
config.setActiveTodoReminder('prompt-unrelated', 'other work');
config.startAutomaticActiveTodoWorkChain('prompt-cron');
config.startAutomaticActiveTodoWorkChain(
'prompt-related-notification',
'prompt-user',
);
expect(config.getActiveTodoReminder('prompt-user')).toBe(
'unfinished user work',
);
expect(config.getActiveTodoReminder('prompt-cron')).toBeUndefined();
expect(config.getActiveTodoReminder('prompt-related-notification')).toBe(
'unfinished user work',
);
expect(
config.getActiveTodoWorkChainOwner(
'prompt-related-notification',
'stale-owner',
),
).toBe('prompt-user');
expect(
config.getActiveTodoWorkChainOwner('prompt-unmapped', 'inherited-owner'),
).toBe('inherited-owner');
expect(config.getActiveTodoReminder('prompt-unrelated')).toBe('other work');
config.endAutomaticActiveTodoWorkChain('prompt-cron');
config.endAutomaticActiveTodoWorkChain('prompt-related-notification');
expect(config.getActiveTodoReminder('prompt-cron')).toBeUndefined();
expect(config.getActiveTodoReminder('prompt-user')).toBe(
'unfinished user work',
);
config.startAutomaticActiveTodoWorkChain(
'prompt-stale-notification',
'prompt-stale-owner',
);
config.setActiveTodoReminder(
'prompt-stale-notification',
'stale automatic work',
);
config.endAutomaticActiveTodoWorkChain('prompt-stale-notification');
expect(config.getActiveTodoReminder('prompt-stale-owner')).toBeUndefined();
});
it('isolates active Todo reminders inherited through child Configs', () => {
const parent = Object.create(Config.prototype) as Config;
const child = Object.create(parent) as Config;
parent.setActiveTodoReminder('parent-prompt', 'parent work');
child.setActiveTodoReminder('child-prompt', 'child work');
child.startActiveTodoWorkChain('child-retry', 'child-prompt');
expect(parent.getActiveTodoReminder('parent-prompt')).toBe('parent work');
expect(parent.getActiveTodoReminder('child-retry')).toBeUndefined();
expect(child.getActiveTodoReminder('parent-prompt')).toBeUndefined();
expect(child.getActiveTodoReminder('child-retry')).toBe('child work');
});
it('clears active Todo reminders for a new session', () => {
const config = new Config(baseParams);
config.setActiveTodoReminder('old-prompt', 'unfinished old work');
config.startActiveTodoWorkChain('old-retry', 'old-prompt');
config.startNewSession('new-session-id');
expect(config.getActiveTodoReminder('old-prompt')).toBeUndefined();
expect(config.getActiveTodoWorkChainOwner('old-retry')).toBe('old-retry');
});
});

View file

@ -251,6 +251,9 @@ const memoryPressureConfigLogger = createDebugLogger('MEMORY_PRESSURE');
const MEMORY_CONTEXT_WARNING_RATIO = 0.15;
/** Re-inject the active Todo reminder every Nth tool turn, not every turn. */
const ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3;
// Default `tools.toolSearch.threshold` (percent of the context window):
// mirrors the settings-schema default in packages/cli.
const DEFAULT_TOOL_SEARCH_THRESHOLD = 10;
@ -1857,6 +1860,9 @@ export class Config {
private readonly gitCoAuthor: GitCoAuthorSettings;
private readonly usageStatisticsEnabled: boolean;
private readonly fileReadCacheDisabled: boolean;
private activeTodoReminders = new Map<string, string>();
private activeTodoWorkChainOwners = new Map<string, string>();
private activeTodoReminderTurns = new Map<string, number>();
private geminiClient!: GeminiClient;
private baseLlmClient!: BaseLlmClient;
private cronScheduler: CronScheduler | null = null;
@ -3668,6 +3674,9 @@ export class Config {
}
this.sessionData = sessionData;
this.pendingRecoveredAgentsNotice = null;
this.getOwnActiveTodoReminders().clear();
this.getOwnActiveTodoWorkChainOwners().clear();
this.getOwnActiveTodoReminderTurns().clear();
setDebugLogSession(this);
this.debugLogger = createDebugLogger();
this.chatRecordingService = this.chatRecordingEnabled
@ -6072,6 +6081,122 @@ export class Config {
return this.geminiClient;
}
private getOwnActiveTodoReminders(): Map<string, string> {
if (!Object.prototype.hasOwnProperty.call(this, 'activeTodoReminders')) {
this.activeTodoReminders = new Map();
}
return this.activeTodoReminders;
}
private getOwnActiveTodoWorkChainOwners(): Map<string, string> {
if (
!Object.prototype.hasOwnProperty.call(this, 'activeTodoWorkChainOwners')
) {
this.activeTodoWorkChainOwners = new Map();
}
return this.activeTodoWorkChainOwners;
}
private getOwnActiveTodoReminderTurns(): Map<string, number> {
if (
!Object.prototype.hasOwnProperty.call(this, 'activeTodoReminderTurns')
) {
this.activeTodoReminderTurns = new Map();
}
return this.activeTodoReminderTurns;
}
getActiveTodoWorkChainOwner(
promptId: string,
fallbackOwner = promptId,
): string {
return (
this.getOwnActiveTodoWorkChainOwners().get(promptId) ?? fallbackOwner
);
}
getActiveTodoReminder(promptId: string): string | undefined {
return this.getOwnActiveTodoReminders().get(
this.getActiveTodoWorkChainOwner(promptId),
);
}
/**
* Reads the reminder for injection, re-issuing it only every
* ACTIVE_TODO_REMINDER_REFRESH_TURNS tool turns: each injected copy lands in
* chat history permanently, so per-turn injection would grow the context
* linearly with tool turns. `force` is for turn-start injections (retry /
* related automatic turns), which always need the context and reset the
* cadence.
*/
takeActiveTodoReminder(promptId: string, force = false): string | undefined {
const owner = this.getActiveTodoWorkChainOwner(promptId);
const reminder = this.getOwnActiveTodoReminders().get(owner);
if (!reminder) return undefined;
const turns = this.getOwnActiveTodoReminderTurns();
const elapsed = (turns.get(owner) ?? 0) + 1;
if (!force && elapsed < ACTIVE_TODO_REMINDER_REFRESH_TURNS) {
turns.set(owner, elapsed);
return undefined;
}
turns.set(owner, 0);
return reminder;
}
setActiveTodoReminder(promptId: string, reminder: string | undefined): void {
const reminders = this.getOwnActiveTodoReminders();
const owner = this.getActiveTodoWorkChainOwner(promptId);
if (reminder) {
reminders.set(owner, reminder);
// The todo_write result itself just presented the full state.
this.getOwnActiveTodoReminderTurns().set(owner, 0);
} else {
reminders.delete(owner);
this.getOwnActiveTodoReminderTurns().delete(owner);
}
}
startActiveTodoWorkChain(promptId: string, continuedFrom?: string): void {
const reminders = this.getOwnActiveTodoReminders();
const owners = this.getOwnActiveTodoWorkChainOwners();
if (!continuedFrom) {
reminders.clear();
owners.clear();
this.getOwnActiveTodoReminderTurns().clear();
owners.set(promptId, promptId);
return;
}
const owner = this.getActiveTodoWorkChainOwner(continuedFrom);
for (const reminderOwner of reminders.keys()) {
if (reminderOwner !== owner) reminders.delete(reminderOwner);
}
owners.clear();
owners.set(promptId, owner);
}
startAutomaticActiveTodoWorkChain(
promptId: string,
continuedFrom?: string,
): void {
const reminders = this.getOwnActiveTodoReminders();
const owners = this.getOwnActiveTodoWorkChainOwners();
const owner = continuedFrom
? this.getActiveTodoWorkChainOwner(continuedFrom)
: promptId;
owners.set(promptId, owner);
if (owner === promptId) reminders.delete(owner);
}
endAutomaticActiveTodoWorkChain(promptId: string): void {
const owners = this.getOwnActiveTodoWorkChainOwners();
const owner = this.getActiveTodoWorkChainOwner(promptId);
owners.delete(promptId);
if (![...owners.values()].includes(owner)) {
this.getOwnActiveTodoReminders().delete(owner);
}
}
/**
* Session-scoped memory pressure monitor. Child Configs created with
* `Object.create(parent)` inherit the parent's monitor through the prototype

View file

@ -207,6 +207,10 @@ function setupGoalClient() {
getModel: vi.fn(() => 'test-model'),
getSkipNextSpeakerCheck: vi.fn(() => false),
getSkipLoopDetection: vi.fn(() => false),
startActiveTodoWorkChain: vi.fn(),
startAutomaticActiveTodoWorkChain: vi.fn(),
endAutomaticActiveTodoWorkChain: vi.fn(),
takeActiveTodoReminder: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => undefined),
hasHooksForEvent: vi.fn(() => false),
getStopHookBlockingCap: vi.fn(() => 8),

View file

@ -564,6 +564,11 @@ describe('Gemini Client (client.ts)', () => {
setStaticSystemPrefix: vi.fn(),
getFullContext: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
getActiveTodoWorkChainOwner: vi.fn((promptId: string) => promptId),
startActiveTodoWorkChain: vi.fn(),
startAutomaticActiveTodoWorkChain: vi.fn(),
endAutomaticActiveTodoWorkChain: vi.fn(),
getProxy: vi.fn().mockReturnValue(undefined),
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
getFileService: vi.fn().mockReturnValue(fileService),
@ -1808,6 +1813,153 @@ describe('Gemini Client (client.ts)', () => {
}
}
it('carries active todos after tool results and clears them for new work', async () => {
const reminder =
'<system-reminder>unfinished todo: run tests</system-reminder>';
vi.mocked(mockConfig.takeActiveTodoReminder).mockReturnValue(reminder);
mockTurnRunFn.mockReturnValue(
(async function* () {
yield { type: GeminiEventType.Content, value: 'response' };
})(),
);
const stream = client.sendMessageStream(
[
{ functionResponse: { name: 'read_file', response: { ok: true } } },
'user changed priority mid-turn',
],
new AbortController().signal,
'prompt-tool-result',
{ type: SendMessageType.ToolResult },
);
for await (const _ of stream) {
// drain
}
const request = mockTurnRunFn.mock.lastCall?.[1] as unknown[];
const functionResponseIndex = request.findIndex(
(part) =>
typeof part === 'object' &&
part !== null &&
'functionResponse' in part,
);
expect(functionResponseIndex).toBeGreaterThanOrEqual(0);
expect(request.indexOf(reminder)).toBeGreaterThan(functionResponseIndex);
expect(request.indexOf(reminder)).toBeLessThan(
request.indexOf('user changed priority mid-turn'),
);
expect(mockConfig.takeActiveTodoReminder).toHaveBeenCalledWith(
'prompt-tool-result',
);
await runTurn(SendMessageType.UserQuery);
expect(mockConfig.startActiveTodoWorkChain).toHaveBeenCalledWith(
'prompt-userQuery',
);
await runTurn(SendMessageType.Cron);
expect(mockConfig.startAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
'prompt-cron',
undefined,
);
expect(mockConfig.endAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
'prompt-cron',
);
await runTurn(SendMessageType.Retry);
expect(mockConfig.startActiveTodoWorkChain).toHaveBeenCalledWith(
'prompt-retry',
'prompt-userQuery',
);
});
it('includes active Todo context on the first retry request', async () => {
const reminder =
'<system-reminder>unfinished todo: run tests</system-reminder>';
vi.mocked(mockConfig.takeActiveTodoReminder).mockReturnValue(reminder);
await runTurn(SendMessageType.UserQuery);
await runTurn(SendMessageType.Retry);
const request = mockTurnRunFn.mock.lastCall?.[1] as unknown[];
expect(request).toContain(reminder);
});
it('continues the carried Todo work chain for related notifications', async () => {
mockTurnRunFn.mockReturnValue(
(async function* () {
yield { type: GeminiEventType.Content, value: 'response' };
})(),
);
const stream = client.sendMessageStream(
[{ text: 'related notification' }],
new AbortController().signal,
'prompt-related-notification',
{
type: SendMessageType.Notification,
todoWorkChainId: 'prompt-owner',
},
);
for await (const _ of stream) {
// drain
}
expect(mockConfig.startAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
'prompt-related-notification',
'prompt-owner',
);
});
it('keeps automatic Todo ownership through its tool-result turns', async () => {
const reminder =
'<system-reminder>unfinished todo: finish automatic work</system-reminder>';
vi.mocked(mockConfig.takeActiveTodoReminder).mockReturnValue(reminder);
mockTurnRunFn
.mockReturnValueOnce(
(async function* () {
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'call-1', name: 'read_file', args: {} },
};
})(),
)
.mockReturnValueOnce(
(async function* () {
yield { type: GeminiEventType.Content, value: 'done' };
})(),
);
for await (const _ of client.sendMessageStream(
[{ text: 'automatic work' }],
new AbortController().signal,
'prompt-automatic',
{ type: SendMessageType.Notification },
)) {
// drain
}
expect(mockConfig.endAutomaticActiveTodoWorkChain).not.toHaveBeenCalled();
for await (const _ of client.sendMessageStream(
[{ functionResponse: { name: 'read_file', response: { ok: true } } }],
new AbortController().signal,
'prompt-automatic',
{ type: SendMessageType.ToolResult },
)) {
// drain
}
expect(mockConfig.takeActiveTodoReminder).toHaveBeenCalledWith(
'prompt-automatic',
);
expect(mockConfig.endAutomaticActiveTodoWorkChain).toHaveBeenCalledWith(
'prompt-automatic',
);
});
it('queues and drains a reminder for newly registered MCP deferred tools', async () => {
const reg = getRegistryMock();
reg.getTool.mockImplementation((n: string) =>

View file

@ -200,6 +200,8 @@ export interface SendMessageOptions {
};
/** Display text for notification messages (persisted for session resume). */
notificationDisplayText?: string;
/** Todo work chain that owns this automatic turn, when it is related. */
todoWorkChainId?: string;
/** Model override from skill execution. When present, overrides the session model for this turn. */
modelOverride?: string;
/** Exact runtime permit authorizing this Goal-bound turn. */
@ -325,6 +327,8 @@ export class GeminiClient {
private readonly loopDetector: LoopDetectionService;
private lastPromptId: string | undefined = undefined;
private activeTodoWorkChainPromptId: string | undefined;
private readonly activeAutomaticTodoWorkChainPromptIds = new Set<string>();
private lastSentIdeContext: IdeContext | undefined;
private forceFullIdeContext = true;
private recentCompletedToolNames: string[] = [];
@ -2447,6 +2451,30 @@ export class GeminiClient {
// Notifications start a fresh Turn with a new prompt_id, so the loop
// detector must reset — otherwise a prior turn's count can trip
// LoopDetected early on the notification turn.
if (messageType === SendMessageType.UserQuery) {
this.activeAutomaticTodoWorkChainPromptIds.clear();
this.config.startActiveTodoWorkChain(prompt_id);
this.activeTodoWorkChainPromptId = prompt_id;
} else if (messageType === SendMessageType.Retry) {
this.config.startActiveTodoWorkChain(
prompt_id,
this.activeTodoWorkChainPromptId,
);
this.activeTodoWorkChainPromptId = prompt_id;
} else if (
messageType === SendMessageType.Cron ||
messageType === SendMessageType.Notification ||
messageType === SendMessageType.Teammate
) {
this.config.startAutomaticActiveTodoWorkChain(
prompt_id,
options?.todoWorkChainId ??
(messageType === SendMessageType.Teammate
? this.activeTodoWorkChainPromptId
: undefined),
);
this.activeAutomaticTodoWorkChainPromptIds.add(prompt_id);
}
const isTopLevelInteraction =
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron ||
@ -2486,6 +2514,7 @@ export class GeminiClient {
// early-return) leaves this `false`, and the `finally` block aborts the
// prefetch as a safety net.
let normalCompletion = false;
let hasToolCalls = false;
// Declared outside the try so the finally block can close it out on
// uncaught-exception exits too; created (when the hook is registered)
// right before the turn's streaming loop below.
@ -2901,6 +2930,39 @@ export class GeminiClient {
requestToSend = [...systemReminders, ...requestToSend];
}
if (
messageType === SendMessageType.Retry ||
messageType === SendMessageType.Cron ||
messageType === SendMessageType.Notification ||
messageType === SendMessageType.Teammate
) {
const activeTodoReminder = this.config.takeActiveTodoReminder(
prompt_id,
true,
);
const alreadyHasActiveTodoReminder = requestToSend.some(
(part) =>
part === activeTodoReminder ||
(typeof part === 'object' &&
part !== null &&
'text' in part &&
part.text === activeTodoReminder),
);
if (activeTodoReminder && !alreadyHasActiveTodoReminder) {
const insertAt = requestToSend.findIndex(
(part) =>
typeof part !== 'object' ||
part === null ||
!('functionResponse' in part),
);
requestToSend.splice(
insertAt < 0 ? requestToSend.length : insertAt,
0,
activeTodoReminder,
);
}
}
if (messageType === SendMessageType.ToolResult) {
const toolResultMemory =
await this.tryConsumeMemoryPrefetch('tool_result');
@ -2915,6 +2977,21 @@ export class GeminiClient {
// text as a separate user message after the tool messages.
requestToSend = [...requestToSend, toolResultMemory.prompt];
}
const activeTodoReminder =
this.config.takeActiveTodoReminder(prompt_id);
if (activeTodoReminder) {
const insertAt = requestToSend.findIndex(
(part) =>
typeof part !== 'object' ||
part === null ||
!('functionResponse' in part),
);
requestToSend.splice(
insertAt < 0 ? requestToSend.length : insertAt,
0,
activeTodoReminder,
);
}
await this.microcompactHistoryBeforeSend(null, {
sizeOnly: true,
pendingContent: createUserContent(requestToSend),
@ -2977,7 +3054,6 @@ export class GeminiClient {
const resultStream = turn.run(model, requestToSend, signal);
let didUpdateIdeContextState = false;
let steerInputSettled = false;
let hasToolCalls = false;
try {
for await (const event of resultStream) {
if (!steerInputSettled) {
@ -3193,6 +3269,7 @@ export class GeminiClient {
}
if (isTopLevelInteraction)
endInteractionSpan(signal.aborted ? 'cancelled' : 'ok');
hasToolCalls = steeredTurn.pendingToolCalls.length > 0;
normalCompletion = true;
return steeredTurn;
}
@ -3525,6 +3602,7 @@ export class GeminiClient {
}
if (isTopLevelInteraction)
endInteractionSpan(signal.aborted ? 'cancelled' : 'ok');
hasToolCalls = hookTurn.pendingToolCalls.length > 0;
// Preserve the pending prefetch: the inner Hook turn we just
// yielded may have produced tool calls, and the caller's next
// ToolResult turn still needs to consume the recall result.
@ -3640,6 +3718,7 @@ export class GeminiClient {
}
if (isTopLevelInteraction)
endInteractionSpan(signal.aborted ? 'cancelled' : 'ok');
hasToolCalls = continueTurn.pendingToolCalls.length > 0;
// Preserve the pending prefetch: same reasoning as the
// `return hookTurn` site above — the recursive Hook turn may
// have produced tool calls whose ToolResult turn still needs
@ -3685,6 +3764,13 @@ export class GeminiClient {
}
throw error;
} finally {
if (
this.activeAutomaticTodoWorkChainPromptIds.has(prompt_id) &&
(!normalCompletion || !hasToolCalls)
) {
this.activeAutomaticTodoWorkChainPromptIds.delete(prompt_id);
this.config.endAutomaticActiveTodoWorkChain(prompt_id);
}
if (!goalPermitReleased && (callerSignal.aborted || !normalCompletion)) {
await releaseGoalPermitOnInterruptedExit();
}

View file

@ -84,6 +84,10 @@ import {
} from '../utils/invocation-context.js';
import { getPlanModeSystemReminder } from './prompts.js';
import { PLAN_MODE_ENTRY_SIBLING_SKIP_MESSAGE } from './plan-mode-entry-policy.js';
import {
promptIdContext,
todoWorkChainContext,
} from '../utils/promptIdContext.js';
type ToolSpanRecord = {
name: string;
@ -737,6 +741,10 @@ describe('CoreToolScheduler', () => {
visionBridge?: boolean;
visionAgent?: boolean;
onToolResultFullTurnModel?: (model: string) => boolean;
getActiveTodoWorkChainOwner?: (
promptId: string,
fallbackOwner?: string,
) => string;
}) {
const ensureTool = vi.fn(
async (name: string) =>
@ -829,6 +837,7 @@ describe('CoreToolScheduler', () => {
isInteractive: () => true,
getInputFormat: () => undefined,
getExperimentalZedIntegration: () => false,
getActiveTodoWorkChainOwner: options.getActiveTodoWorkChainOwner,
} as unknown as Config,
onAllToolCallsComplete,
onToolCallsUpdate,
@ -858,6 +867,8 @@ describe('CoreToolScheduler', () => {
promptId: 'unrelated-prompt',
};
let observedContext: InvocationContextV1 | undefined;
let observedPromptId: string | undefined;
let observedTodoWorkChainId: string | undefined;
const tool = new MockTool({
name: 'approval-context-tool',
getDefaultPermission: async () => 'ask',
@ -869,12 +880,15 @@ describe('CoreToolScheduler', () => {
}),
execute: async () => {
observedContext = getInvocationContext();
observedPromptId = promptIdContext.getStore();
observedTodoWorkChainId = todoWorkChainContext.getStore();
return { llmContent: 'ok', returnDisplay: 'ok' };
},
});
const { scheduler, onToolCallsUpdate } = createSchedulerForLegacyToolTests({
toolsByName: new Map([[tool.name, tool]]),
approvalMode: ApprovalMode.DEFAULT,
getActiveTodoWorkChainOwner: () => 'mapped-work-chain',
});
await runWithInvocationContext(invocationContext, () =>
@ -896,13 +910,17 @@ describe('CoreToolScheduler', () => {
'awaiting_approval',
)) as WaitingToolCall;
await runWithInvocationContext(unrelatedContext, () =>
waiting.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
await todoWorkChainContext.run('stale-work-chain', () =>
runWithInvocationContext(unrelatedContext, () =>
waiting.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
),
),
);
expect(observedContext).toEqual(invocationContext);
expect(observedPromptId).toBe(invocationContext.promptId);
expect(observedTodoWorkChainId).toBe('mapped-work-chain');
});
it('isolates enter_plan_mode as a batch boundary and preserves its full reminder', async () => {

View file

@ -70,6 +70,10 @@ import {
type AvailableSkillEntry,
} from '../tools/skill-utils.js';
import { escapeSystemReminderTags } from '../utils/xml.js';
import {
promptIdContext,
todoWorkChainContext,
} from '../utils/promptIdContext.js';
import { unescapePath, PATH_ARG_KEYS } from '../utils/paths.js';
import type { MemoryPressureMonitor } from '../services/memoryPressureMonitor.js';
import { CONCURRENCY_SAFE_KINDS, isShellProgressData } from '../tools/tools.js';
@ -4217,6 +4221,15 @@ export class CoreToolScheduler {
}
}
const inheritedTodoWorkChainId = todoWorkChainContext.getStore();
const todoWorkChainId =
this.config.getActiveTodoWorkChainOwner?.(
scheduledCall.request.prompt_id,
inheritedTodoWorkChainId,
) ??
inheritedTodoWorkChainId ??
scheduledCall.request.prompt_id;
if (invocation instanceof ShellToolInvocation) {
const setPidCallback = (pid: number) => {
this.toolCalls = this.toolCalls.map((tc) =>
@ -4250,20 +4263,28 @@ export class CoreToolScheduler {
);
};
this.safelyAddToolArgumentsAttributes(span, invocation.params);
promise = invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
setPidCallback,
setPromoteAbortControllerCallback,
canPromoteForegroundShell,
promise = todoWorkChainContext.run(todoWorkChainId, () =>
promptIdContext.run(scheduledCall.request.prompt_id, () =>
invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
setPidCallback,
setPromoteAbortControllerCallback,
canPromoteForegroundShell,
),
),
);
} else {
this.safelyAddToolArgumentsAttributes(span, invocation.params);
promise = invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
promise = todoWorkChainContext.run(todoWorkChainId, () =>
promptIdContext.run(scheduledCall.request.prompt_id, () =>
invocation.execute(
execSignal,
liveOutputCallback,
shellExecutionConfig,
),
),
);
}

View file

@ -26,6 +26,7 @@ import {
statusFilePathFor,
type ShellTaskRegistration,
} from './backgroundShellRegistry.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
let tmpDirs: string[] = [];
@ -67,6 +68,15 @@ function makeEntry(
describe('BackgroundShellRegistry', () => {
describe('register / get / getAll', () => {
it('captures the Todo work-chain owner at registration', () => {
const reg = new BackgroundShellRegistry();
const entry = todoWorkChainContext.run('work-chain-1', () =>
reg.register(makeEntry()),
);
expect(entry.todoWorkChainId).toBe('work-chain-1');
});
it('round-trips a registered entry by id', () => {
const reg = new BackgroundShellRegistry();
const e = makeEntry({ shellId: 'a' });

View file

@ -23,6 +23,7 @@ import * as fs from 'node:fs';
import type { TaskBase, TaskRegistration } from '../agents/tasks/types.js';
import { atomicWriteFileSync } from '../utils/atomicFileWrite.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
import { escapeXml } from '../utils/xml.js';
const debugLogger = createDebugLogger('BACKGROUND_SHELLS');
@ -244,6 +245,7 @@ export interface ShellNotificationMeta {
shellId: string;
status: BackgroundShellStatus;
exitCode?: number;
todoWorkChainId?: string;
}
export type BackgroundShellNotificationCallback = (
@ -309,6 +311,7 @@ export class BackgroundShellRegistry {
entry.outputFile = registration.outputPath;
entry.outputOffset = 0;
entry.notified = false;
entry.todoWorkChainId ??= todoWorkChainContext.getStore();
this.entries.set(entry.shellId, entry);
this.writeStatusFile(entry);
this.fireRegister(entry);
@ -550,6 +553,7 @@ export class BackgroundShellRegistry {
shellId: entry.shellId,
status: entry.status,
exitCode: entry.exitCode,
todoWorkChainId: entry.todoWorkChainId,
};
try {

View file

@ -92,6 +92,7 @@ export interface CronJob {
delivery?: CronTaskDelivery;
/** One-shot that was due while no owning session ran — fired late. */
missed?: boolean;
todoWorkChainId?: string;
}
/**
@ -104,6 +105,7 @@ interface SessionWakeup {
fireAtMs: number;
prompt: string;
createdAt: number;
todoWorkChainId?: string;
}
/**
@ -219,6 +221,7 @@ function wakeupToJob(wakeup: SessionWakeup): CronJob {
expiresAt: Infinity,
fireAtMs: wakeup.fireAtMs,
jitterMs: 0,
todoWorkChainId: wakeup.todoWorkChainId,
};
}
@ -426,6 +429,7 @@ export class CronScheduler {
scheduleWakeup(
delaySeconds: number,
prompt: string,
todoWorkChainId?: string,
): {
id: string;
scheduledFor: string;
@ -472,7 +476,13 @@ export class CronScheduler {
if (replacedId) {
debugLogger.debug(`Replacing pending wakeup ${replacedId}`);
}
this.wakeups.set(id, { id, fireAtMs, prompt, createdAt: now });
this.wakeups.set(id, {
id,
fireAtMs,
prompt,
createdAt: now,
todoWorkChainId,
});
debugLogger.debug(
`Wakeup ${id} scheduled for ${new Date(fireAtMs).toISOString()} ` +
`(delay=${clampedDelaySeconds}s)`,

View file

@ -10,6 +10,7 @@ import {
MonitorRegistry,
type MonitorTaskRegistration,
} from './monitorRegistry.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
function createEntry(
overrides: Partial<MonitorTaskRegistration> = {},
@ -51,6 +52,14 @@ describe('MonitorRegistry', () => {
expect(registry.get('mon-1')).toBe(entry);
});
it('captures the Todo work-chain owner at registration', () => {
const entry = todoWorkChainContext.run('work-chain-1', () =>
registry.register(createEntry()),
);
expect(entry.todoWorkChainId).toBe('work-chain-1');
});
it('emits event notification via callback', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);

View file

@ -18,6 +18,7 @@
import * as path from 'node:path';
import { sanitizeFilenameComponent } from '../agents/agent-transcript.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
import { stripDisplayControlChars } from '../utils/terminalSafe.js';
import { escapeXml } from '../utils/xml.js';
import type { TaskBase, TaskRegistration } from '../agents/tasks/types.js';
@ -118,6 +119,7 @@ export interface MonitorNotificationMeta {
eventCount: number;
toolUseId?: string;
ownerAgentId?: string;
todoWorkChainId?: string;
}
export type MonitorNotificationCallback = (
@ -184,6 +186,7 @@ export class MonitorRegistry {
entry.kind = 'monitor';
entry.outputOffset = 0;
entry.notified = false;
entry.todoWorkChainId ??= todoWorkChainContext.getStore();
this.monitors.set(entry.monitorId, entry);
debugLogger.info(`Registered monitor: ${entry.monitorId}`);
this.resetIdleTimer(entry);
@ -524,6 +527,7 @@ export class MonitorRegistry {
eventCount: entry.eventCount,
toolUseId: entry.toolUseId,
ownerAgentId: entry.ownerAgentId,
todoWorkChainId: entry.todoWorkChainId,
};
this.dispatchNotification(entry, displayLine, xmlParts.join('\n'), meta);
@ -577,6 +581,7 @@ export class MonitorRegistry {
eventCount: entry.eventCount,
toolUseId: entry.toolUseId,
ownerAgentId: entry.ownerAgentId,
todoWorkChainId: entry.todoWorkChainId,
};
this.dispatchNotification(entry, displayLine, xmlParts.join('\n'), meta);

View file

@ -12,6 +12,7 @@ import { Storage } from '../config/storage.js';
import { CronScheduler } from '../services/cronScheduler.js';
import type { Config } from '../config/config.js';
import { LoopWakeupTool } from './loop-wakeup.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
// The scheduling math (clamp / wasClamped / second-precise fire time) is
// covered in cronScheduler.test.ts `session wakeups`. These tests cover the
@ -103,7 +104,9 @@ describe('LoopWakeupTool', () => {
reason: 'CI is still running',
});
const result = await invocation.execute(new AbortController().signal);
const result = await todoWorkChainContext.run('work-chain-1', () =>
invocation.execute(new AbortController().signal),
);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Session-only one-shot');
@ -114,6 +117,7 @@ describe('LoopWakeupTool', () => {
expect(scheduler.list()[0]).toMatchObject({
cronExpr: '@wakeup',
prompt: 'continue loop',
todoWorkChainId: 'work-chain-1',
});
expect(scheduler.size).toBe(1);
});

View file

@ -15,6 +15,7 @@ import {
clampWakeupSeconds,
} from '../services/cronScheduler.js';
import { getErrorMessage } from '../utils/errors.js';
import { todoWorkChainContext } from '../utils/promptIdContext.js';
export interface LoopWakeupParams {
delaySeconds: number;
@ -83,7 +84,11 @@ class LoopWakeupInvocation extends BaseToolInvocation<
};
}
const { id, scheduledFor, clampedDelaySeconds, wasClamped, replacedId } =
scheduler.scheduleWakeup(this.params.delaySeconds, prompt);
scheduler.scheduleWakeup(
this.params.delaySeconds,
prompt,
todoWorkChainContext.getStore(),
);
const reason = this.params.reason?.trim();
const llmContent = [

View file

@ -15,6 +15,7 @@ import type { Config } from '../config/config.js';
import type { AggregatedHookResult } from '../hooks/hookAggregator.js';
import { Storage } from '../config/storage.js';
import { atomicWriteFile } from '../utils/atomicFileWrite.js';
import { promptIdContext } from '../utils/promptIdContext.js';
// Mock fs modules
vi.mock('fs/promises');
@ -37,7 +38,8 @@ describe('TodoWriteTool', () => {
mockConfig = {
getSessionId: () => 'test-session-123',
getHookSystem: () => undefined,
} as Config;
setActiveTodoReminder: vi.fn(),
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);
mockAbortSignal = new AbortController().signal;
vi.clearAllMocks();
@ -152,7 +154,9 @@ describe('TodoWriteTool', () => {
mockAtomicWrite.mockResolvedValue(undefined);
const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);
const result = await promptIdContext.run('todo-prompt', () =>
invocation.execute(mockAbortSignal),
);
expect(result.llmContent).toContain(
'Todos have been modified successfully',
@ -172,6 +176,48 @@ describe('TodoWriteTool', () => {
expect.stringContaining('"todos"'),
{ encoding: 'utf-8' },
);
expect(mockConfig.setActiveTodoReminder).toHaveBeenCalledWith(
'todo-prompt',
expect.stringContaining('Task 1'),
);
});
it('bounds the active Todo reminder', async () => {
const params: TodoWriteParams = {
todos: [{ id: '1', content: 'x'.repeat(5000), status: 'in_progress' }],
};
const enoentError = new Error('ENOENT') as Error & { code: string };
enoentError.code = 'ENOENT';
mockFs.readFile.mockRejectedValue(enoentError);
mockFs.mkdir.mockResolvedValue(undefined);
mockAtomicWrite.mockResolvedValue(undefined);
await promptIdContext.run('todo-prompt', () =>
tool.build(params).execute(mockAbortSignal),
);
const reminder = vi.mocked(mockConfig.setActiveTodoReminder).mock
.lastCall?.[1];
expect(reminder).toContain('[truncated]');
expect(reminder?.length).toBeLessThan(1100);
});
it('skips active Todo reminder when no prompt id is active', async () => {
const params: TodoWriteParams = {
todos: [{ id: '1', content: 'Task 1', status: 'pending' }],
};
const enoentError = new Error('ENOENT') as Error & { code: string };
enoentError.code = 'ENOENT';
mockFs.readFile.mockRejectedValue(enoentError);
mockFs.mkdir.mockResolvedValue(undefined);
mockAtomicWrite.mockResolvedValue(undefined);
const result = await tool.build(params).execute(mockAbortSignal);
expect(result.llmContent).toContain(
'Todos have been modified successfully',
);
expect(mockConfig.setActiveTodoReminder).not.toHaveBeenCalled();
});
it('should replace todos with new ones', async () => {
@ -194,7 +240,9 @@ describe('TodoWriteTool', () => {
mockAtomicWrite.mockResolvedValue(undefined);
const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);
const result = await promptIdContext.run('todo-prompt', () =>
invocation.execute(mockAbortSignal),
);
expect(result.llmContent).toContain(
'Todos have been modified successfully',
@ -214,6 +262,10 @@ describe('TodoWriteTool', () => {
expect.stringMatching(/"Updated Task"/),
{ encoding: 'utf-8' },
);
const reminder = vi.mocked(mockConfig.setActiveTodoReminder).mock
.lastCall?.[1];
expect(reminder).toContain('New Task');
expect(reminder).not.toContain('Updated Task');
});
it('should handle file write errors', async () => {
@ -256,7 +308,9 @@ describe('TodoWriteTool', () => {
);
const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);
const result = await promptIdContext.run('todo-prompt', () =>
invocation.execute(mockAbortSignal),
);
expect(result.llmContent).toContain('Todo list has been cleared');
expect(result.llmContent).toContain('<system-reminder>');
@ -271,6 +325,10 @@ describe('TodoWriteTool', () => {
expect.stringContaining('"todos"'),
{ encoding: 'utf-8' },
);
expect(mockConfig.setActiveTodoReminder).toHaveBeenCalledWith(
'todo-prompt',
undefined,
);
});
it('should block todo creation when validation hook returns block', async () => {
@ -794,7 +852,7 @@ describe('TodoWriteTool runtime output directory', () => {
mockConfig = {
getSessionId: () => 'runtime-session',
getHookSystem: () => undefined,
} as Config;
} as unknown as Config;
tool = new TodoWriteTool(mockConfig);
mockAbortSignal = new AbortController().signal;
Storage.setRuntimeBaseDir(null);

View file

@ -17,9 +17,12 @@ import { ToolDisplayNames, ToolNames } from './tool-names.js';
import { atomicWriteFile } from '../utils/atomicFileWrite.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { detectTodoChanges, HookPhase, type TodoItem } from '../hooks/types.js';
import { escapeSystemReminderTags } from '../utils/xml.js';
import { promptIdContext } from '../utils/promptIdContext.js';
export type { TodoItem } from '../hooks/types.js';
const debugLogger = createDebugLogger('TODO_WRITE');
const MAX_ACTIVE_TODO_CONTEXT_CHARS = 800;
export interface TodoWriteParams {
todos: TodoItem[];
@ -250,6 +253,27 @@ class TodoWriteToolInvocation extends BaseToolInvocation<
// 4. Write new todos AFTER all validation passes
await writeTodosToFile(finalTodos, sessionId);
const unfinishedTodos = finalTodos.filter(
(todo) => todo.status !== 'completed',
);
const promptId = promptIdContext.getStore();
if (promptId) {
const serializedTodos = escapeSystemReminderTags(
unfinishedTodos
.map((todo) => `- [${todo.status}] ${todo.content}`)
.join('\n'),
);
const todoContext = serializedTodos.slice(
0,
MAX_ACTIVE_TODO_CONTEXT_CHARS,
);
this.config.setActiveTodoReminder(
promptId,
unfinishedTodos.length > 0
? `<system-reminder>\nThe current task still has unfinished todo items:\n${todoContext}${serializedTodos.length > todoContext.length ? '\n[truncated]' : ''}\nKeep the todo list current and continue the task. Do not treat a successful intermediate tool call as task completion.\n</system-reminder>`
: undefined,
);
}
// 5. POST-WRITE PHASE: Execute hooks for side effects (logging, HTTP sync, etc.)
// These hooks can now safely perform side effects knowing data is persisted

View file

@ -7,3 +7,4 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export const promptIdContext = new AsyncLocalStorage<string>();
export const todoWorkChainContext = new AsyncLocalStorage<string>();