diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 7bb89b157..d6aec9c79 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -62,6 +62,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "terminal\nApp\n IHostTerminalService" as terminal_app #D6EAF8
rectangle "sessionTerminal\nSession\n ISessionTerminalService" as terminal_session #D5F5E3
rectangle "modelProvider\nSession\n IModelProvider (seed)" as modelProvider #D5F5E3
+ rectangle "todo\nSession\n ISessionTodoService" as todo #D5F5E3
}
package "Agent scope (per agent)" #FDF5E6 {
@@ -98,7 +99,6 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "fullCompaction\nAgent\n IAgentFullCompactionService" as fullCompaction #FDEBD0
rectangle "microCompaction\nAgent\n IAgentMicroCompactionService" as microCompaction #FDEBD0
rectangle "externalHooks\nAgent\n IAgentExternalHooksService" as externalHooks #FDEBD0
- rectangle "todoList\nAgent\n IAgentTodoListService" as todoList #FDEBD0
rectangle "usage\nAgent\n IAgentUsageService" as usage #FDEBD0
rectangle "rpc\nAgent\n IAgentRPCService" as rpc #FDEBD0
rectangle "fileTools\nAgent\n Read/Write/Grep/Glob tools" as fileTools #FDEBD0
@@ -301,11 +301,7 @@ microCompaction --> loop #34495E
externalHooks --> config #34495E
externalHooks --> bootstrap #34495E
externalHooks --> plugin #34495E
-todoList --> contextMemory #34495E
-todoList --> profile #34495E
-todoList --> toolStore #34495E
-todoList --> toolRegistry #34495E
-todoList --> contextInjector #34495E
+todo --> agent_lifecycle #34495E
usage --> wireRecord #34495E
usage --> record #34495E
rpc --> prompt #34495E
@@ -354,6 +350,7 @@ userTool ..> wireRecord #16A085 : tools.register_/unregister_user_tool
profile ..> wireRecord #16A085 : config.update / tools.set_active_tools
contextMemory ..> wireRecord #16A085 : context.splice
cron ..> wireRecord #16A085 : cron.add / delete / cursor
+todo ..> wireRecord #16A085 : todo.set
fullCompaction ..> wireRecord #16A085 : full_compaction.begin/cancel/complete
fullCompaction ..> loop #16A085 : hooks.onContextOverflow
permissionRules ..> wireRecord #16A085 : permission.rules.add / record_approval_result
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index 550125a97..5c00e6070 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -156,7 +156,7 @@ const DOMAIN_LAYER = new Map([
['profile', 4],
['prompt', 4],
['replayBuilder', 4],
- ['todoList', 4],
+ ['todo', 4],
['web', 4],
// L5 — agent task management
['agentTask', 5],
@@ -248,6 +248,8 @@ function domainFromRel(rel, { exemptRootFile }) {
* - `swarm>agentLifecycle`: swarm spawns/manages sub-agents.
* - `cron>agentLifecycle` : cron coordinator steers the main agent.
* - `cron>sessionContext`: cron scheduler reads session identity for store filtering.
+ * - `todo>agentLifecycle` : todo binds its tool/reminder into agents and its
+ * resume resumer into the main agent via lifecycle handle.
*
* Post-rebase-v2 restructuring introduced cross-domain type sharing between
* L3 (registries/capabilities) and L4 (agent behaviour). The tool contract
@@ -281,6 +283,7 @@ const ALLOWED_EXCEPTIONS = new Set([
'swarm>agentLifecycle',
'cron>agentLifecycle',
'cron>sessionContext',
+ 'todo>agentLifecycle',
'wireRecord>hooks',
// L3/L4 type-sharing: tool contract + execution hook contexts now live in
// `tool`; the remaining upward import is a `loop` error/event helper.
diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
index e2cc2d503..69632b22c 100644
--- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
+++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
@@ -19,13 +19,8 @@ import { IAgentLoopService, type TurnContextOverflowContext } from '#/agent/loop
import { isAbortError } from '#/agent/loop/errors';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentRecordService } from '#/agent/record';
-import {
- TODO_STORE_KEY,
- renderTodoList,
- type TodoItem,
-} from '#/agent/todoList/tools/todo-list';
-import { IAgentToolState } from '#/agent/toolState';
import { IAgentTurnService } from '#/agent/turn';
+import { ISessionTodoService, renderTodoList, type TodoItem } from '#/session/todo';
import {
APIContextOverflowError,
APIEmptyResponseError,
@@ -109,7 +104,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService,
@IAgentProfileService private readonly profile: IAgentProfileService,
- @IAgentToolState private readonly toolStore: IAgentToolState,
+ @ISessionTodoService private readonly todo: ISessionTodoService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentTurnService turnService: IAgentTurnService,
@@ -486,12 +481,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
}
private currentTodos(): readonly TodoItem[] {
- const raw = this.toolStore.data()[TODO_STORE_KEY];
- if (!Array.isArray(raw)) return [];
- return raw.filter(isTodoItem).map((todo) => ({
- title: todo.title,
- status: todo.status,
- }));
+ return this.todo.getTodos();
}
private tokenCountWithPending(): number {
diff --git a/packages/agent-core-v2/src/agent/todoList/index.ts b/packages/agent-core-v2/src/agent/todoList/index.ts
deleted file mode 100644
index d075bcd47..000000000
--- a/packages/agent-core-v2/src/agent/todoList/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * `todoList` domain barrel - re-exports the todoList service contract and implementation.
- */
-
-export * from './todoList';
-export * from './todoListReminder';
-export * from './todoListService';
diff --git a/packages/agent-core-v2/src/agent/todoList/todoList.ts b/packages/agent-core-v2/src/agent/todoList/todoList.ts
deleted file mode 100644
index eac458f94..000000000
--- a/packages/agent-core-v2/src/agent/todoList/todoList.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { createDecorator } from "#/_base/di";
-
-export interface IAgentTodoListService {
- readonly _serviceBrand: undefined;
-}
-
-export const IAgentTodoListService = createDecorator('agentTodoListService');
diff --git a/packages/agent-core-v2/src/agent/todoList/todoListService.ts b/packages/agent-core-v2/src/agent/todoList/todoListService.ts
deleted file mode 100644
index c2f989aa4..000000000
--- a/packages/agent-core-v2/src/agent/todoList/todoListService.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import {
- Disposable,
-} from "#/_base/di";
-import { IInstantiationService } from "#/_base/di/instantiation";
-import {
- TODO_LIST_TOOL_NAME,
- TODO_STORE_KEY,
- TodoListTool,
- readTodoItems,
- type TodoItem,
-} from '#/agent/todoList/tools/todo-list';
-import {
- TODO_LIST_REMINDER_VARIANT,
- todoListStaleReminder,
-} from './todoListReminder';
-import { IAgentContextMemoryService } from '#/agent/contextMemory';
-import { IAgentContextInjectorService } from '#/agent/contextInjector';
-import { IAgentProfileService } from '#/agent/profile';
-import { IAgentToolRegistryService } from '#/agent/toolRegistry';
-import { IAgentToolState } from '#/agent/toolState';
-import { IAgentTodoListService } from './todoList';
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-
-export class AgentTodoListService extends Disposable implements IAgentTodoListService {
- declare readonly _serviceBrand: undefined;
-
- constructor(
- @IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
- @IAgentProfileService private readonly profile: IAgentProfileService,
- @IAgentToolState private readonly toolStore: IAgentToolState,
- @IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
- @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
- @IInstantiationService private readonly instantiationService: IInstantiationService,
- ) {
- super();
- this._register(toolRegistry.register(instantiationService.createInstance(TodoListTool)));
- this._register(
- dynamicInjector.register(TODO_LIST_REMINDER_VARIANT, () => this.staleReminder()),
- );
- }
-
- private getTodos(): readonly TodoItem[] {
- return readTodoItems(this.toolStore.data()[TODO_STORE_KEY]);
- }
-
- private staleReminder(): string | undefined {
- return todoListStaleReminder({
- active: this.profile.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'),
- history: this.context.get(),
- todos: this.getTodos(),
- });
- }
-}
-
-registerScopedService(
- LifecycleScope.Agent,
- IAgentTodoListService,
- AgentTodoListService,
- InstantiationType.Eager,
- 'todoList',
-);
diff --git a/packages/agent-core-v2/src/agent/todoList/tools/todo-list.ts b/packages/agent-core-v2/src/agent/todoList/tools/todo-list.ts
deleted file mode 100644
index 8a80d2dab..000000000
--- a/packages/agent-core-v2/src/agent/todoList/tools/todo-list.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-/**
- * TodoListTool — structured TODO list management tool.
- *
- * The LLM uses this tool to maintain a visible plan of sub-tasks during
- * plan-mode workflows and multi-step operations. A single tool serves
- * both reads and writes:
- *
- * - `resolveExecution({ todos: [...] })` — replace the full list
- * - `resolveExecution({ todos: [] })` — clear the list
- * - `resolveExecution({})` — query current list (no mutation)
- *
- * Storage: todos live in the agent-level tool store. Writes go through
- * `tools.update_store`, so the store update is visible on wire replay.
- */
-
-import { z } from 'zod';
-
-import type { BuiltinTool } from '#/agent/tool';
-import type { ToolExecution } from '#/agent/tool';
-import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
-import { IAgentToolState } from '#/agent/toolState';
-import DESCRIPTION from './todo-list.md?raw';
-import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw';
-
-// ── TODO state shape ─────────────────────────────────────────────────
-
-export const TODO_LIST_TOOL_NAME = 'TodoList' as const;
-export const TODO_STORE_KEY = 'todo';
-
-export type TodoStatus = 'pending' | 'in_progress' | 'done';
-
-export interface TodoItem {
- readonly title: string;
- readonly status: TodoStatus;
-}
-
-export function readTodoItems(raw: unknown): readonly TodoItem[] {
- if (!Array.isArray(raw)) return [];
- return raw.filter(isTodoItem).map((todo) => ({
- title: todo.title,
- status: todo.status,
- }));
-}
-
-declare module '#/agent/toolState' {
- interface ToolStoreData {
- todo: readonly TodoItem[];
- }
-}
-
-// ── Schema ───────────────────────────────────────────────────────────
-
-const TodoItemSchema = z.object({
- title: z.string().min(1).describe('Short, actionable title for the todo.'),
- status: z.enum(['pending', 'in_progress', 'done']).describe('Current status of the todo.'),
-});
-
-export interface TodoListInput {
- todos?: Array<{ title: string; status: TodoStatus }>;
-}
-
-export const TodoListInputSchema: z.ZodType = z.object({
- todos: z
- .array(TodoItemSchema)
- .optional()
- .describe(
- 'The updated todo list. Omit to read the current todo list without making changes. Pass an empty array to clear the list.',
- ),
-});
-
-// ── Implementation ───────────────────────────────────────────────────
-
-export function renderTodoList(todos: readonly TodoItem[], title = 'Current todo list:'): string {
- if (todos.length === 0) {
- return 'Todo list is empty.';
- }
- const lines = todos.map((t) => {
- const marker = statusMarker(t.status);
- return ` ${marker} ${t.title}`;
- });
- return [title, ...lines].join('\n');
-}
-
-function statusMarker(status: TodoStatus): string {
- switch (status) {
- case 'pending':
- return '[pending]';
- case 'in_progress':
- return '[in_progress]';
- case 'done':
- return '[done]';
- default: {
- const _exhaustive: never = status;
- return _exhaustive;
- }
- }
-}
-
-function isTodoItem(value: unknown): value is TodoItem {
- if (typeof value !== 'object' || value === null) return false;
- const record = value as Record;
- return typeof record['title'] === 'string' && isTodoStatus(record['status']);
-}
-
-function isTodoStatus(value: unknown): value is TodoStatus {
- return value === 'pending' || value === 'in_progress' || value === 'done';
-}
-
-export class TodoListTool implements BuiltinTool {
- readonly name = TODO_LIST_TOOL_NAME;
- readonly description: string = DESCRIPTION;
- readonly parameters: Record = toInputJsonSchema(TodoListInputSchema);
-
- constructor(@IAgentToolState private readonly store: IAgentToolState) {}
-
- resolveExecution(args: TodoListInput): ToolExecution {
- const description =
- args.todos === undefined
- ? 'Reading todo list'
- : args.todos.length === 0
- ? 'Clearing todo list'
- : 'Updating todo list';
- return {
- description,
- approvalRule: this.name,
- execute: async () => {
- // Query mode — return the current list without mutation.
- if (args.todos === undefined) {
- const current = this.getTodos();
- return { isError: false, output: renderTodoList(current) };
- }
-
- // Write mode — replace the full list and return the new state.
- this.setTodos(args.todos);
- const stored = this.getTodos();
- const output =
- stored.length === 0
- ? 'Todo list cleared.'
- : `Todo list updated.\n${renderTodoList(stored)}\n\n${TODO_LIST_WRITE_REMINDER.trim()}`;
- return { isError: false, output };
- },
- };
- }
-
- private getTodos(): readonly TodoItem[] {
- const todos = this.store.get(TODO_STORE_KEY);
- return todos ?? [];
- }
-
- private setTodos(todos: readonly TodoItem[]): void {
- this.store.set(
- TODO_STORE_KEY,
- todos.map((todo) => ({ title: todo.title, status: todo.status })),
- );
- }
-}
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index 9594a7eb4..a53c5e344 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -103,7 +103,7 @@ export * from '#/agent/rpc';
export * from '#/agent/scopeContext';
export * from '#/session/btw';
export * from '#/session/swarm';
-export * from '#/agent/todoList';
+export * from '#/session/todo';
export * from '#/agent/tool';
export * from '#/agent/toolExecutor';
import '#/agent/toolRegistry';
diff --git a/packages/agent-core-v2/src/session/todo/index.ts b/packages/agent-core-v2/src/session/todo/index.ts
new file mode 100644
index 000000000..b382b1de3
--- /dev/null
+++ b/packages/agent-core-v2/src/session/todo/index.ts
@@ -0,0 +1,12 @@
+/**
+ * `todo` domain barrel — re-exports the todo data shape, the session service
+ * contract and implementation, the stale reminder, and the `TodoListTool`.
+ * Importing this barrel registers the `ISessionTodoService` binding into the
+ * scope registry.
+ */
+
+export * from './todoItem';
+export * from './todoListReminder';
+export * from './sessionTodo';
+export * from './sessionTodoService';
+export * from './tools/todo-list';
diff --git a/packages/agent-core-v2/src/session/todo/sessionTodo.ts b/packages/agent-core-v2/src/session/todo/sessionTodo.ts
new file mode 100644
index 000000000..548067e85
--- /dev/null
+++ b/packages/agent-core-v2/src/session/todo/sessionTodo.ts
@@ -0,0 +1,28 @@
+/**
+ * `todo` domain (L4) — `ISessionTodoService` contract.
+ *
+ * The session-shared todo list: an in-memory list materialized from the main
+ * agent's `todo.set` wire records, mutated through `setTodos` (which appends a
+ * fresh `todo.set` to the main agent's wire), and readable by every agent in
+ * the session. Bound at Session scope.
+ */
+
+import { createDecorator } from '#/_base/di';
+import type { Event } from '#/_base/event';
+
+import type { TodoItem } from './todoItem';
+
+export interface ISessionTodoService {
+ readonly _serviceBrand: undefined;
+
+ /** Current in-memory todo list (the materialized main-agent wire state). */
+ getTodos(): readonly TodoItem[];
+ /** Replace the whole list: appends a `todo.set` to the main agent's wire. */
+ setTodos(todos: readonly TodoItem[]): void;
+ /** Clear the list (equivalent to `setTodos([])`). */
+ clear(): void;
+ /** Fires after every `setTodos` with the new list. */
+ readonly onDidChange: Event;
+}
+
+export const ISessionTodoService = createDecorator('sessionTodoService');
diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts
new file mode 100644
index 000000000..9c2581be5
--- /dev/null
+++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts
@@ -0,0 +1,164 @@
+/**
+ * `todo` domain (L4) — `ISessionTodoService` implementation.
+ *
+ * Holds the session's shared in-memory todo list. Every mutation appends a
+ * `todo.set` record to the main agent's wire (the single source of truth and
+ * replayable timeline); on resume the main agent's wire replay runs the
+ * `todo.set` resumer and rebuilds the in-memory list. Binds the `TodoListTool`
+ * and the stale-todo reminder into every agent (`onDidCreate`), and the resume
+ * resumer into the main agent (`onDidCreateMain`), borrowing each agent's
+ * services through its `IAgentScopeHandle.accessor`. Per-agent bindings are
+ * disposed when the agent is disposed. Bound at Session scope.
+ */
+
+import { Disposable, toDisposable, type IDisposable } from '#/_base/di';
+import { InstantiationType } from '#/_base/di/extensions';
+import { IInstantiationService } from '#/_base/di/instantiation';
+import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { Emitter } from '#/_base/event';
+
+import { IAgentContextInjectorService } from '#/agent/contextInjector';
+import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { IAgentProfileService } from '#/agent/profile';
+import { IAgentRecordService } from '#/agent/record';
+import { IAgentToolRegistryService } from '#/agent/toolRegistry';
+import { IAgentLifecycleService } from '#/session/agentLifecycle';
+
+import { ISessionTodoService } from './sessionTodo';
+import { TODO_LIST_TOOL_NAME, type TodoItem } from './todoItem';
+import { TODO_LIST_REMINDER_VARIANT, todoListStaleReminder } from './todoListReminder';
+import { TodoListTool } from './tools/todo-list';
+
+declare module '#/agent/wireRecord' {
+ interface WireRecordMap {
+ 'todo.set': {
+ todos: readonly TodoItem[];
+ };
+ }
+}
+
+const MAIN_AGENT_ID = 'main';
+
+export class SessionTodoService extends Disposable implements ISessionTodoService {
+ declare readonly _serviceBrand: undefined;
+
+ private todos: readonly TodoItem[] = [];
+ private readonly onDidChangeEmitter = this._register(new Emitter());
+ readonly onDidChange = this.onDidChangeEmitter.event;
+
+ /** Per-agent bindings (tool + reminder, plus the resume resumer for main). */
+ private readonly agentBindings = new Map();
+
+ constructor(
+ @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
+ ) {
+ super();
+
+ this._register(this.agentLifecycle.onDidCreate((handle) => this.bindAgent(handle)));
+ this._register(this.agentLifecycle.onDidCreateMain((handle) => this.bindMainWire(handle)));
+ this._register(
+ this.agentLifecycle.onDidDispose((agentId) => this.disposeAgentBindings(agentId)),
+ );
+
+ for (const handle of this.agentLifecycle.list()) {
+ this.bindAgent(handle);
+ }
+ const main = this.agentLifecycle.getHandle(MAIN_AGENT_ID);
+ if (main !== undefined) {
+ this.bindMainWire(main);
+ }
+
+ this._register(
+ toDisposable(() => {
+ for (const agentId of [...this.agentBindings.keys()]) {
+ this.disposeAgentBindings(agentId);
+ }
+ this.todos = [];
+ }),
+ );
+ }
+
+ getTodos(): readonly TodoItem[] {
+ return this.todos;
+ }
+
+ setTodos(todos: readonly TodoItem[]): void {
+ const next: readonly TodoItem[] = todos.map((todo) => ({
+ title: todo.title,
+ status: todo.status,
+ }));
+ this.todos = next;
+ this.appendTodoSet(next);
+ this.onDidChangeEmitter.fire(next);
+ }
+
+ clear(): void {
+ this.setTodos([]);
+ }
+
+ private appendTodoSet(todos: readonly TodoItem[]): void {
+ const main = this.agentLifecycle.getHandle(MAIN_AGENT_ID);
+ if (main === undefined) return;
+ const record = main.accessor.get(IAgentRecordService);
+ record.append({ type: 'todo.set', todos } as never);
+ }
+
+ private bindMainWire(handle: IAgentScopeHandle): void {
+ const record = handle.accessor.get(IAgentRecordService);
+ const disposable = record.define('todo.set', {
+ resume: (r) => {
+ this.todos = (r as unknown as { todos: readonly TodoItem[] }).todos;
+ },
+ });
+ this.trackAgentBinding(handle.id, disposable);
+ }
+
+ private bindAgent(handle: IAgentScopeHandle): void {
+ const instantiation = handle.accessor.get(IInstantiationService);
+ const registry = handle.accessor.get(IAgentToolRegistryService);
+ const tool = instantiation.createInstance(TodoListTool);
+ this.trackAgentBinding(handle.id, registry.register(tool, { source: 'builtin' }));
+
+ const injector = handle.accessor.get(IAgentContextInjectorService);
+ this.trackAgentBinding(
+ handle.id,
+ injector.register(TODO_LIST_REMINDER_VARIANT, () => this.staleReminder(handle)),
+ );
+ }
+
+ private staleReminder(handle: IAgentScopeHandle): string | undefined {
+ const memory = handle.accessor.get(IAgentContextMemoryService);
+ const profile = handle.accessor.get(IAgentProfileService);
+ return todoListStaleReminder({
+ active: profile.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'),
+ history: memory.get(),
+ todos: this.todos,
+ });
+ }
+
+ private trackAgentBinding(agentId: string, disposable: IDisposable): void {
+ const list = this.agentBindings.get(agentId);
+ if (list === undefined) {
+ this.agentBindings.set(agentId, [disposable]);
+ } else {
+ list.push(disposable);
+ }
+ }
+
+ private disposeAgentBindings(agentId: string): void {
+ const bindings = this.agentBindings.get(agentId);
+ if (bindings === undefined) return;
+ for (const disposable of bindings) {
+ disposable.dispose();
+ }
+ this.agentBindings.delete(agentId);
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ ISessionTodoService,
+ SessionTodoService,
+ InstantiationType.Eager,
+ 'todo',
+);
diff --git a/packages/agent-core-v2/src/session/todo/todoItem.ts b/packages/agent-core-v2/src/session/todo/todoItem.ts
new file mode 100644
index 000000000..3d18d29e6
--- /dev/null
+++ b/packages/agent-core-v2/src/session/todo/todoItem.ts
@@ -0,0 +1,61 @@
+/**
+ * `todo` domain (L4) — todo item data shape and pure render helpers.
+ *
+ * `TodoItem` / `TodoStatus` are the persistent shape carried by the `todo.set`
+ * wire record and rendered by the `TodoListTool` and the stale reminder. Pure
+ * and scope-less — no scoped state lives here. The session todo list itself is
+ * owned by `ISessionTodoService`.
+ */
+
+export const TODO_LIST_TOOL_NAME = 'TodoList' as const;
+
+export type TodoStatus = 'pending' | 'in_progress' | 'done';
+
+export interface TodoItem {
+ readonly title: string;
+ readonly status: TodoStatus;
+}
+
+export function readTodoItems(raw: unknown): readonly TodoItem[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.filter(isTodoItem).map((todo) => ({
+ title: todo.title,
+ status: todo.status,
+ }));
+}
+
+export function isTodoItem(value: unknown): value is TodoItem {
+ if (typeof value !== 'object' || value === null) return false;
+ const record = value as Record;
+ return typeof record['title'] === 'string' && isTodoStatus(record['status']);
+}
+
+function isTodoStatus(value: unknown): value is TodoStatus {
+ return value === 'pending' || value === 'in_progress' || value === 'done';
+}
+
+export function renderTodoList(todos: readonly TodoItem[], title = 'Current todo list:'): string {
+ if (todos.length === 0) {
+ return 'Todo list is empty.';
+ }
+ const lines = todos.map((t) => {
+ const marker = statusMarker(t.status);
+ return ` ${marker} ${t.title}`;
+ });
+ return [title, ...lines].join('\n');
+}
+
+function statusMarker(status: TodoStatus): string {
+ switch (status) {
+ case 'pending':
+ return '[pending]';
+ case 'in_progress':
+ return '[in_progress]';
+ case 'done':
+ return '[done]';
+ default: {
+ const _exhaustive: never = status;
+ return _exhaustive;
+ }
+ }
+}
diff --git a/packages/agent-core-v2/src/agent/todoList/todoListReminder.ts b/packages/agent-core-v2/src/session/todo/todoListReminder.ts
similarity index 87%
rename from packages/agent-core-v2/src/agent/todoList/todoListReminder.ts
rename to packages/agent-core-v2/src/session/todo/todoListReminder.ts
index bbfdd2b0c..a620ae9bb 100644
--- a/packages/agent-core-v2/src/agent/todoList/todoListReminder.ts
+++ b/packages/agent-core-v2/src/session/todo/todoListReminder.ts
@@ -1,9 +1,16 @@
-import {
- TODO_LIST_TOOL_NAME,
- type TodoItem,
-} from '#/agent/todoList/tools/todo-list';
+/**
+ * `todo` domain (L4) — pure stale-todo reminder logic.
+ *
+ * Computes the `todo_list_reminder` context injection from the agent's context
+ * history (turns since the last `TodoList` write / last reminder) and the
+ * current session todo list. No scoped state — `SessionTodoService` supplies
+ * the inputs and registers the provider into each agent's context injector.
+ */
+
import type { ContextMessage } from '#/agent/contextMemory';
+import { TODO_LIST_TOOL_NAME, type TodoItem } from './todoItem';
+
export const TODO_LIST_REMINDER_VARIANT = 'todo_list_reminder';
const TODO_LIST_REMINDER_TURNS_SINCE_WRITE = 10;
diff --git a/packages/agent-core-v2/src/agent/todoList/tools/todo-list-write-reminder.md b/packages/agent-core-v2/src/session/todo/tools/todo-list-write-reminder.md
similarity index 100%
rename from packages/agent-core-v2/src/agent/todoList/tools/todo-list-write-reminder.md
rename to packages/agent-core-v2/src/session/todo/tools/todo-list-write-reminder.md
diff --git a/packages/agent-core-v2/src/agent/todoList/tools/todo-list.md b/packages/agent-core-v2/src/session/todo/tools/todo-list.md
similarity index 100%
rename from packages/agent-core-v2/src/agent/todoList/tools/todo-list.md
rename to packages/agent-core-v2/src/session/todo/tools/todo-list.md
diff --git a/packages/agent-core-v2/src/session/todo/tools/todo-list.ts b/packages/agent-core-v2/src/session/todo/tools/todo-list.ts
new file mode 100644
index 000000000..ffe4564c8
--- /dev/null
+++ b/packages/agent-core-v2/src/session/todo/tools/todo-list.ts
@@ -0,0 +1,86 @@
+/**
+ * `todo` domain (L4) — `TodoListTool`, the structured TODO list tool.
+ *
+ * A single tool serves both reads and writes:
+ *
+ * - `resolveExecution({ todos: [...] })` — replace the full list
+ * - `resolveExecution({ todos: [] })` — clear the list
+ * - `resolveExecution({})` — query the current list
+ *
+ * The list is session-shared: the tool reads/writes `ISessionTodoService`,
+ * which persists every change as a `todo.set` wire record on the main agent.
+ * Instantiated per agent by `SessionTodoService` and registered into each
+ * agent's tool registry.
+ */
+
+import { z } from 'zod';
+
+import type { BuiltinTool, ToolExecution } from '#/agent/tool';
+import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
+
+import { ISessionTodoService } from '#/session/todo/sessionTodo';
+import {
+ TODO_LIST_TOOL_NAME,
+ renderTodoList,
+ type TodoItem,
+ type TodoStatus,
+} from '#/session/todo/todoItem';
+
+import DESCRIPTION from './todo-list.md?raw';
+import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw';
+
+const TodoItemSchema = z.object({
+ title: z.string().min(1).describe('Short, actionable title for the todo.'),
+ status: z.enum(['pending', 'in_progress', 'done']).describe('Current status of the todo.'),
+});
+
+export interface TodoListInput {
+ todos?: Array<{ title: string; status: TodoStatus }>;
+}
+
+export const TodoListInputSchema: z.ZodType = z.object({
+ todos: z
+ .array(TodoItemSchema)
+ .optional()
+ .describe(
+ 'The updated todo list. Omit to read the current todo list without making changes. Pass an empty array to clear the list.',
+ ),
+});
+
+export class TodoListTool implements BuiltinTool {
+ readonly name = TODO_LIST_TOOL_NAME;
+ readonly description: string = DESCRIPTION;
+ readonly parameters: Record = toInputJsonSchema(TodoListInputSchema);
+
+ constructor(@ISessionTodoService private readonly todo: ISessionTodoService) {}
+
+ resolveExecution(args: TodoListInput): ToolExecution {
+ const description =
+ args.todos === undefined
+ ? 'Reading todo list'
+ : args.todos.length === 0
+ ? 'Clearing todo list'
+ : 'Updating todo list';
+ return {
+ description,
+ approvalRule: this.name,
+ execute: async () => {
+ if (args.todos === undefined) {
+ return { isError: false, output: renderTodoList(this.todo.getTodos()) };
+ }
+
+ const next: readonly TodoItem[] = args.todos.map((todo) => ({
+ title: todo.title,
+ status: todo.status,
+ }));
+ this.todo.setTodos(next);
+ const stored = this.todo.getTodos();
+ const output =
+ stored.length === 0
+ ? 'Todo list cleared.'
+ : `Todo list updated.\n${renderTodoList(stored)}\n\n${TODO_LIST_WRITE_REMINDER.trim()}`;
+ return { isError: false, output };
+ },
+ };
+ }
+}
diff --git a/packages/agent-core-v2/test/contextInjector/manager.test.ts b/packages/agent-core-v2/test/contextInjector/manager.test.ts
index c01650384..a6f9cd535 100644
--- a/packages/agent-core-v2/test/contextInjector/manager.test.ts
+++ b/packages/agent-core-v2/test/contextInjector/manager.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
+import { DisposableStore } from '#/_base/di/lifecycle';
import {
createServices,
type TestInstantiationService,
@@ -12,10 +12,6 @@ import { IAgentLoopService } from '#/agent/loop';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
-import { IAgentTodoListService, TODO_LIST_REMINDER_VARIANT } from '#/agent/todoList';
-import { AgentTodoListService } from '#/agent/todoList/todoListService';
-import { IAgentToolRegistryService } from '#/agent/toolRegistry';
-import { IAgentToolState } from '#/agent/toolState';
import { IAgentTurnService } from '#/agent/turn';
import { registerContextMemoryServices } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubTurnWithHooks } from '../turn/stubs';
@@ -24,10 +20,6 @@ type InjectableContextInjector = IAgentContextInjectorService & {
inject(): Promise;
};
-type ContextInjectorInternals = {
- entries: Set<{ variant: string }>;
-};
-
function injector(ix: TestInstantiationService): InjectableContextInjector {
return ix.get(IAgentContextInjectorService) as InjectableContextInjector;
}
@@ -249,44 +241,3 @@ describe('AgentContextInjectorService', () => {
]);
});
});
-
-describe('AgentContextInjectorService registration', () => {
- let disposables: DisposableStore;
- let ix: TestInstantiationService;
-
- beforeEach(() => {
- disposables = new DisposableStore();
- ix = createServices(disposables, {
- base: [registerContextMemoryServices],
- strict: true,
- additionalServices: (reg) => {
- reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
- reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
- reg.define(IAgentSystemReminderService, AgentSystemReminderService);
- reg.define(IAgentContextInjectorService, AgentContextInjectorService);
- reg.definePartialInstance(IAgentProfileService, {
- isToolActive: () => false,
- });
- reg.definePartialInstance(IAgentToolState, {
- data: () => ({}),
- });
- reg.definePartialInstance(IAgentToolRegistryService, {
- register: () => toDisposable(() => {}),
- });
- reg.define(IAgentTodoListService, AgentTodoListService);
- },
- });
- });
-
- afterEach(() => disposables.dispose());
-
- it('registers the todo-list reminder when the todo-list service is resolved', () => {
- ix.get(IAgentTodoListService);
-
- const entries = [
- ...(injector(ix) as unknown as ContextInjectorInternals).entries,
- ];
-
- expect(entries.some((entry) => entry.variant === TODO_LIST_REMINDER_VARIANT)).toBe(true);
- });
-});
diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts
index 1df90eec2..425784492 100644
--- a/packages/agent-core-v2/test/fullCompaction/full.test.ts
+++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts
@@ -33,9 +33,8 @@ import {
IAgentMicroCompactionService,
IOAuthService,
IAgentProfileService,
- IAgentToolState,
+ ISessionTodoService,
} from '#/index';
-import { TODO_STORE_KEY } from '#/agent/todoList/tools/todo-list';
type GenerateFn = NonNullable;
@@ -2080,7 +2079,7 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
- ctx.get(IAgentToolState).set(TODO_STORE_KEY, [
+ ctx.get(ISessionTodoService).setTodos([
{ title: 'Fix the auth bug', status: 'in_progress' },
{ title: 'Add tests', status: 'pending' },
]);
diff --git a/packages/agent-core-v2/test/todo/session-todo.test.ts b/packages/agent-core-v2/test/todo/session-todo.test.ts
new file mode 100644
index 000000000..d1181c68a
--- /dev/null
+++ b/packages/agent-core-v2/test/todo/session-todo.test.ts
@@ -0,0 +1,271 @@
+import { describe, expect, it } from 'vitest';
+
+import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation';
+import { IInstantiationService } from '#/_base/di/instantiation';
+import { toDisposable, type IDisposable } from '#/_base/di/lifecycle';
+import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope';
+import { Emitter } from '#/_base/event';
+import { IAgentContextInjectorService } from '#/agent/contextInjector';
+import { IAgentContextMemoryService } from '#/agent/contextMemory';
+import { IAgentProfileService } from '#/agent/profile';
+import { IAgentRecordService } from '#/agent/record';
+import { IAgentToolRegistryService } from '#/agent/toolRegistry';
+import { IAgentLifecycleService } from '#/session/agentLifecycle';
+import {
+ ISessionTodoService,
+ SessionTodoService,
+ TODO_LIST_REMINDER_VARIANT,
+ type TodoItem,
+} from '#/session/todo';
+
+interface RecordedTodoSet {
+ readonly todos: readonly TodoItem[];
+}
+
+interface FakeAgent {
+ readonly handle: IAgentScopeHandle;
+ readonly registeredTools: string[];
+ readonly registeredVariants: string[];
+ readonly appended: RecordedTodoSet[];
+ readonly resumers: Array<(record: RecordedTodoSet) => void>;
+}
+
+function makeFakeAgent(agentId: string): FakeAgent {
+ const registeredTools: string[] = [];
+ const registeredVariants: string[] = [];
+ const appended: RecordedTodoSet[] = [];
+ const resumers: Array<(record: RecordedTodoSet) => void> = [];
+
+ const recordStub = {
+ _serviceBrand: undefined,
+ append: (record: RecordedTodoSet) => {
+ appended.push(record);
+ },
+ define: (_type: string, facets: { resume?: (r: RecordedTodoSet) => void }) => {
+ if (facets.resume !== undefined) resumers.push(facets.resume);
+ return toDisposable(() => {});
+ },
+ signal: () => {},
+ on: () => toDisposable(() => {}),
+ hooks: {},
+ };
+
+ const registryStub = {
+ _serviceBrand: undefined,
+ register: (tool: { name: string }) => {
+ registeredTools.push(tool.name);
+ return toDisposable(() => {});
+ },
+ list: () => [],
+ resolve: () => undefined,
+ hooks: {},
+ };
+
+ const injectorStub = {
+ _serviceBrand: undefined,
+ register: (variant: string) => {
+ registeredVariants.push(variant);
+ return toDisposable(() => {});
+ },
+ };
+
+ const instantiationStub = {
+ createInstance: (ctor: { name: string }) => ({ name: ctor.name }),
+ };
+
+ const memoryStub = {
+ _serviceBrand: undefined,
+ get: () => [],
+ };
+
+ const profileStub = {
+ _serviceBrand: undefined,
+ isToolActive: () => false,
+ };
+
+ const accessor: ServicesAccessor = {
+ get: (id: ServiceIdentifier): T => {
+ if (id === IAgentRecordService) return recordStub as unknown as T;
+ if (id === IAgentToolRegistryService) return registryStub as unknown as T;
+ if (id === IAgentContextInjectorService) return injectorStub as unknown as T;
+ if (id === IInstantiationService) return instantiationStub as unknown as T;
+ if (id === IAgentContextMemoryService) return memoryStub as unknown as T;
+ if (id === IAgentProfileService) return profileStub as unknown as T;
+ throw new Error(`unexpected service request in fake agent: ${String(id)}`);
+ },
+ };
+
+ const handle: IAgentScopeHandle = {
+ id: agentId,
+ kind: LifecycleScope.Agent,
+ accessor,
+ dispose: () => {},
+ };
+
+ return { handle, registeredTools, registeredVariants, appended, resumers };
+}
+
+interface LifecycleStub {
+ readonly service: IAgentLifecycleService;
+ readonly fireCreate: (handle: IAgentScopeHandle) => void;
+ readonly fireCreateMain: (handle: IAgentScopeHandle) => void;
+ readonly fireDispose: (agentId: string) => void;
+}
+
+function makeLifecycleStub(handles: readonly IAgentScopeHandle[] = []): LifecycleStub {
+ const onDidCreate = new Emitter();
+ const onDidCreateMain = new Emitter();
+ const onDidDispose = new Emitter();
+ const byId = new Map(handles.map((h) => [h.id, h]));
+
+ const service: IAgentLifecycleService = {
+ _serviceBrand: undefined,
+ onDidCreate: onDidCreate.event,
+ onDidCreateMain: onDidCreateMain.event,
+ onDidDispose: onDidDispose.event,
+ getHandle: (id: string) => byId.get(id),
+ list: () => [...byId.values()],
+ create: async () => {
+ throw new Error('not implemented');
+ },
+ notifyMainCreated: () => {},
+ fork: async () => {
+ throw new Error('not implemented');
+ },
+ run: () => {
+ throw new Error('not implemented');
+ },
+ remove: async () => {},
+ };
+
+ return {
+ service,
+ fireCreate: (h) => {
+ byId.set(h.id, h);
+ onDidCreate.fire(h);
+ },
+ fireCreateMain: (h) => {
+ byId.set(h.id, h);
+ onDidCreateMain.fire(h);
+ },
+ fireDispose: (id) => {
+ byId.delete(id);
+ onDidDispose.fire(id);
+ },
+ };
+}
+
+describe('SessionTodoService', () => {
+ it('starts empty and updates in-memory list on setTodos', () => {
+ const lifecycle = makeLifecycleStub();
+ const service = new SessionTodoService(lifecycle.service);
+
+ expect(service.getTodos()).toEqual([]);
+
+ const next: TodoItem[] = [
+ { title: 'a', status: 'pending' },
+ { title: 'b', status: 'in_progress' },
+ ];
+ service.setTodos(next);
+ expect(service.getTodos()).toEqual(next);
+
+ service.clear();
+ expect(service.getTodos()).toEqual([]);
+ });
+
+ it('fires onDidChange after each setTodos', () => {
+ const lifecycle = makeLifecycleStub();
+ const service = new SessionTodoService(lifecycle.service);
+
+ const seen: Array = [];
+ const d = service.onDidChange((todos) => seen.push(todos));
+ service.setTodos([{ title: 'x', status: 'pending' }]);
+ service.setTodos([{ title: 'y', status: 'done' }]);
+ d.dispose();
+
+ expect(seen).toEqual([
+ [{ title: 'x', status: 'pending' }],
+ [{ title: 'y', status: 'done' }],
+ ]);
+ });
+
+ it('appends a todo.set record to the main agent wire on setTodos', () => {
+ const main = makeFakeAgent('main');
+ const lifecycle = makeLifecycleStub([main.handle]);
+ const service = new SessionTodoService(lifecycle.service);
+
+ service.setTodos([{ title: 'persist me', status: 'in_progress' }]);
+
+ expect(main.appended).toEqual([
+ { type: 'todo.set', todos: [{ title: 'persist me', status: 'in_progress' }] },
+ ]);
+ });
+
+ it('does not append to the wire when the main agent is absent', () => {
+ const lifecycle = makeLifecycleStub();
+ const service = new SessionTodoService(lifecycle.service);
+ // Should not throw even without a main agent.
+ expect(() => service.setTodos([{ title: 'x', status: 'pending' }])).not.toThrow();
+ expect(service.getTodos()).toEqual([{ title: 'x', status: 'pending' }]);
+ });
+
+ it('binds the TodoList tool and reminder into every created agent', () => {
+ const lifecycle = makeLifecycleStub();
+ const service = new SessionTodoService(lifecycle.service);
+ void service;
+
+ const main = makeFakeAgent('main');
+ const sub = makeFakeAgent('agent-1');
+ lifecycle.fireCreate(main.handle);
+ lifecycle.fireCreate(sub.handle);
+
+ expect(main.registeredTools).toContain('TodoListTool');
+ expect(main.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT);
+ expect(sub.registeredTools).toContain('TodoListTool');
+ expect(sub.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT);
+ });
+
+ it('registers the todo.set resume resumer only on the main agent', () => {
+ const main = makeFakeAgent('main');
+ const sub = makeFakeAgent('agent-1');
+ const lifecycle = makeLifecycleStub([main.handle, sub.handle]);
+ const service = new SessionTodoService(lifecycle.service);
+ void service;
+
+ expect(main.resumers).toHaveLength(1);
+ expect(sub.resumers).toHaveLength(0);
+ });
+
+ it('rebuilds the in-memory list when a todo.set record is resumed', () => {
+ const main = makeFakeAgent('main');
+ const lifecycle = makeLifecycleStub([main.handle]);
+ const service = new SessionTodoService(lifecycle.service);
+
+ const resumer = main.resumers[0];
+ expect(resumer).toBeDefined();
+ resumer!({ todos: [{ title: 'restored', status: 'done' }] });
+
+ expect(service.getTodos()).toEqual([{ title: 'restored', status: 'done' }]);
+ });
+
+ it('disposes per-agent bindings when the agent is disposed', () => {
+ const lifecycle = makeLifecycleStub();
+ const service = new SessionTodoService(lifecycle.service);
+ const main = makeFakeAgent('main');
+ lifecycle.fireCreate(main.handle);
+
+ expect(main.registeredTools).toHaveLength(1);
+ // Disposal should not throw and should leave the service usable.
+ expect(() => lifecycle.fireDispose('main')).not.toThrow();
+ expect(service.getTodos()).toEqual([]);
+ });
+
+ it('satisfies the ISessionTodoService contract', () => {
+ const lifecycle = makeLifecycleStub();
+ const service: ISessionTodoService = new SessionTodoService(lifecycle.service);
+ expect(typeof service.getTodos).toBe('function');
+ expect(typeof service.setTodos).toBe('function');
+ expect(typeof service.clear).toBe('function');
+ expect(typeof service.onDidChange).toBe('function');
+ });
+});
diff --git a/packages/agent-core-v2/test/todoList/todo-list-reminder.test.ts b/packages/agent-core-v2/test/todo/todo-list-reminder.test.ts
similarity index 95%
rename from packages/agent-core-v2/test/todoList/todo-list-reminder.test.ts
rename to packages/agent-core-v2/test/todo/todo-list-reminder.test.ts
index 2094965ed..7360e73a0 100644
--- a/packages/agent-core-v2/test/todoList/todo-list-reminder.test.ts
+++ b/packages/agent-core-v2/test/todo/todo-list-reminder.test.ts
@@ -1,8 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ContextMessage } from '#/agent/contextMemory';
-import { todoListStaleReminder } from '#/agent/todoList/todoListReminder';
-import type { TodoItem } from '#/agent/todoList/tools/todo-list';
+import { todoListStaleReminder, type TodoItem } from '#/session/todo';
function assistantMessage(): ContextMessage {
return {
@@ -51,7 +50,7 @@ function priorTodoReminder(): ContextMessage {
};
}
-describe('TodoListReminderInjector', () => {
+describe('todoListStaleReminder', () => {
it('skips reminder injection when TodoList is not active', async () => {
const history = Array.from({ length: 10 }, () => assistantMessage());
const result = todoListStaleReminder({
diff --git a/packages/agent-core-v2/test/todoList/todo-list.test.ts b/packages/agent-core-v2/test/todo/todo-list.test.ts
similarity index 87%
rename from packages/agent-core-v2/test/todoList/todo-list.test.ts
rename to packages/agent-core-v2/test/todo/todo-list.test.ts
index 13f1d058c..0be8bf692 100644
--- a/packages/agent-core-v2/test/todoList/todo-list.test.ts
+++ b/packages/agent-core-v2/test/todo/todo-list.test.ts
@@ -2,33 +2,32 @@ import { describe, expect, it } from 'vitest';
import {
TODO_LIST_TOOL_NAME,
- TODO_STORE_KEY,
TodoListInputSchema,
TodoListTool,
+ type ISessionTodoService,
type TodoItem,
-} from '#/agent/todoList/tools/todo-list';
-import type { IAgentToolState } from '#/agent/toolState';
+} from '#/session/todo';
import { executeTool } from '../tools/fixtures/execute-tool';
const signal = new AbortController().signal;
-function makeStore(initial: readonly TodoItem[] = []): {
- readonly store: IAgentToolState;
+function makeTodoService(initial: readonly TodoItem[] = []): {
+ readonly service: ISessionTodoService;
readonly getTodos: () => readonly TodoItem[];
} {
let todos = [...initial];
return {
- store: {
+ service: {
_serviceBrand: undefined,
- get: (key: string) => (key === TODO_STORE_KEY ? todos : undefined),
- set: (key: string, value: unknown) => {
- if (key === TODO_STORE_KEY) {
- todos = [...(value as readonly TodoItem[])];
- }
+ getTodos: () => todos,
+ setTodos: (next: readonly TodoItem[]) => {
+ todos = next.map((todo) => ({ title: todo.title, status: todo.status }));
},
- data: () => ({ [TODO_STORE_KEY]: todos }),
- hooks: { onUpdated: { register: () => ({ dispose: () => {} }) } },
- } as unknown as IAgentToolState,
+ clear: () => {
+ todos = [];
+ },
+ onDidChange: () => ({ dispose: () => {} }),
+ },
getTodos: () => todos,
};
}
@@ -37,8 +36,8 @@ function makeTool(initial: readonly TodoItem[] = []): {
readonly tool: TodoListTool;
readonly getTodos: () => readonly TodoItem[];
} {
- const { store, getTodos } = makeStore(initial);
- return { tool: new TodoListTool(store), getTodos };
+ const { service, getTodos } = makeTodoService(initial);
+ return { tool: new TodoListTool(service), getTodos };
}
describe('TodoListTool', () => {
@@ -46,7 +45,6 @@ describe('TodoListTool', () => {
const { tool } = makeTool();
expect(TODO_LIST_TOOL_NAME).toBe('TodoList');
- expect(TODO_STORE_KEY).toBe('todo');
expect(tool.name).toBe(TODO_LIST_TOOL_NAME);
expect(tool.description.length).toBeGreaterThan(0);
expect(TodoListInputSchema.safeParse({}).success).toBe(true);
@@ -99,7 +97,7 @@ describe('TodoListTool', () => {
expect(getTodos()).toEqual([{ title: 'existing', status: 'in_progress' }]);
});
- it('write mode replaces the list and defensively copies todos into the store', async () => {
+ it('write mode replaces the list and defensively copies todos into the service', async () => {
const { tool, getTodos } = makeTool();
const todos: TodoItem[] = [
{ title: 'first', status: 'pending' },