From b3ac27e79a72f509a87ebfb176c162ab0fb50aea Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 3 Jul 2026 23:30:55 +0800 Subject: [PATCH] refactor(agent-core-v2): make todo session-shared and wire-only - move todo from agent scope (`IAgentTodoListService`) to session scope (`ISessionTodoService`); the list is now shared across every agent in a session instead of per-agent. - persist every change as a `todo.set` record on the main agent's wire, which is the single source of truth and a replayable timeline; drop the agent tool-store `'todo'` key and the atomic-doc persistence store. - bind `TodoListTool` and the stale reminder into every agent via `IAgentLifecycleService`, and register the `todo.set` resume resumer on the main agent. - migrate `fullCompaction` to read `ISessionTodoService`; register the `todo` domain at L4 with a `todo>agentLifecycle` layer exception; refresh the DI scope/domain diagram. --- .../agent-core-v2/docs/di-scope-domains.puml | 9 +- .../scripts/check-domain-layers.mjs | 5 +- .../fullCompaction/fullCompactionService.ts | 16 +- .../agent-core-v2/src/agent/todoList/index.ts | 7 - .../src/agent/todoList/todoList.ts | 7 - .../src/agent/todoList/todoListService.ts | 62 ---- .../src/agent/todoList/tools/todo-list.ts | 156 ---------- packages/agent-core-v2/src/index.ts | 2 +- .../agent-core-v2/src/session/todo/index.ts | 12 + .../src/session/todo/sessionTodo.ts | 28 ++ .../src/session/todo/sessionTodoService.ts | 164 +++++++++++ .../src/session/todo/todoItem.ts | 61 ++++ .../todo}/todoListReminder.ts | 15 +- .../todo}/tools/todo-list-write-reminder.md | 0 .../todo}/tools/todo-list.md | 0 .../src/session/todo/tools/todo-list.ts | 86 ++++++ .../test/contextInjector/manager.test.ts | 51 +--- .../test/fullCompaction/full.test.ts | 5 +- .../test/todo/session-todo.test.ts | 271 ++++++++++++++++++ .../todo-list-reminder.test.ts | 5 +- .../test/{todoList => todo}/todo-list.test.ts | 34 ++- 21 files changed, 665 insertions(+), 331 deletions(-) delete mode 100644 packages/agent-core-v2/src/agent/todoList/index.ts delete mode 100644 packages/agent-core-v2/src/agent/todoList/todoList.ts delete mode 100644 packages/agent-core-v2/src/agent/todoList/todoListService.ts delete mode 100644 packages/agent-core-v2/src/agent/todoList/tools/todo-list.ts create mode 100644 packages/agent-core-v2/src/session/todo/index.ts create mode 100644 packages/agent-core-v2/src/session/todo/sessionTodo.ts create mode 100644 packages/agent-core-v2/src/session/todo/sessionTodoService.ts create mode 100644 packages/agent-core-v2/src/session/todo/todoItem.ts rename packages/agent-core-v2/src/{agent/todoList => session/todo}/todoListReminder.ts (87%) rename packages/agent-core-v2/src/{agent/todoList => session/todo}/tools/todo-list-write-reminder.md (100%) rename packages/agent-core-v2/src/{agent/todoList => session/todo}/tools/todo-list.md (100%) create mode 100644 packages/agent-core-v2/src/session/todo/tools/todo-list.ts create mode 100644 packages/agent-core-v2/test/todo/session-todo.test.ts rename packages/agent-core-v2/test/{todoList => todo}/todo-list-reminder.test.ts (95%) rename packages/agent-core-v2/test/{todoList => todo}/todo-list.test.ts (87%) 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' },