diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index f467bd16e9..e8ab078bab 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-29-todo-write-tool.md -2026-06-29-todo-write-tool.md: f1fd8a6b273e569b752f477352e4877e556fc054 -2026-06-29-todo-write-tool.zh.md: f5cf3d96dd179bfba5dcac58b437bfc54214e1f9 +2026-06-29-todo-write-tool.md: c4cf64b7876bd8b80df80fe6fc27005715f96b5e +2026-06-29-todo-write-tool.zh.md: 7c1785cdd525397621c8cdee63e1eb4f793a94f2 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index f1fd8a6b27..c4cf64b787 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -34,7 +34,7 @@ Each list belongs to the calling agent session, and non-agent calls are rejected ### Validation: the cheap middle -The schema enforces type/required/enum. Beyond that, `execute` rejects empty or duplicate `content`: enforce the cheap invariants that make a plan *coherent* (no blank tasks, no dupes), but leave ordering, active-task discipline, and keeping the list current to the model via the tool description. A rejected write returns an `isError` result so the model self-corrects. The original design also capped the list at one `in_progress` task; that cap was removed for parallel work — the [parallel in-progress Agent Note](2026-07-26-todo-parallel-in-progress.md) owns that decision. +The schema enforces type/required/enum. Beyond that, `execute` rejects empty or duplicate `content` and, when `allowParallelInProgress` is `false`, more than one active task. Ordering and keeping the list current remain model disciplines expressed in the tool description. A rejected write returns an `isError` result so the model self-corrects. The required deployment policy and the durable invariant's independence from it are owned by the [parallel in-progress Agent Note](2026-07-26-todo-parallel-in-progress.md). ## Why no cordis-catalog entry / no `@mode` diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index f5cf3d96dd..7c1785cdd5 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -34,7 +34,7 @@ claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2) ### 校验:低成本的中间路线 -schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重复的 `content`:强制执行使计划*连贯*的低成本不变式(无空任务、无重复),但将排序、活跃任务纪律和保持列表最新通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。原始设计还将列表限制为最多一个 `in_progress` 任务;该上限已为并行工作移除——[并行 in-progress Agent Note](2026-07-26-todo-parallel-in-progress.md) 拥有该决定。 +schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重复的 `content`,并在 `allowParallelInProgress` 为 `false` 时拒绝超过一个活跃任务。排序和保持列表最新仍通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。必填的部署策略及持久日志不变式独立于该策略这一约定,由[并行 in-progress Agent Note](2026-07-26-todo-parallel-in-progress.md) 负责。 ## 为何没有 cordis-catalog 条目 / 没有 `@mode` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 17328d20da..fd78214a2d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -277,8 +277,8 @@ function buildAlphaLog(): SessionEvent[] { } // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are - // in_progress: the tool permits several, so both surfaces must render a - // parallel plan rather than the first active item alone. + // in_progress: this fixture chooses the parallel policy, so both surfaces + // must render a parallel plan rather than the first active item alone. const fixtureTodos = [ { content: '梳理需求', status: 'completed' }, { content: '实现 fixture 样本', status: 'in_progress' }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 6248de95fc..b964091a61 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -44,6 +44,8 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index 1ec32d9966..4195aeca8d 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -17,14 +19,22 @@ function event(todos: unknown): SessionEvent { } describe('todo snapshot invariants', () => { - it('accepts a unique whole-list snapshot, including several active items', async () => { - const ctx = await setup() - expect(() => { ctx.emit('session/event', {} as Session, event([ + it('accepts historical and live parallel snapshots under the single-active tool policy', async () => { + const todos = [ { content: 'Inspect state', status: 'completed' }, { content: 'Apply fix', status: 'in_progress' }, { content: 'Watch background build', status: 'in_progress' }, { content: 'Run checks', status: 'pending' }, - ])) }).not.toThrow() + ] as const + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolTodo, { allowParallelInProgress: false }) + ctx.sessions.create().append('todo/write', { todos: [...todos] }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(TodoInvariant).then(() => undefined)).resolves.toBeUndefined() + expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).not.toThrow() }) it.each([ diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 7a3e329cb4..58e5434acf 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -124,20 +124,23 @@ describe('tool-todo real Loader composition through cordis.yml', () => { expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) }, 30_000) - it('fails loading when allowParallelInProgress is omitted', async () => { + it.each([ + { label: 'is omitted', configLines: [], failure: '$.allowParallelInProgress missing required value' }, + { label: 'is not boolean', configLines: [' allowParallelInProgress: "no"'], failure: '$.allowParallelInProgress' }, + ])('fails loading when allowParallelInProgress $label', async ({ configLines, failure }) => { // loader.await() is all-settled; configuration failure leaves a FAILED // entry and escapes as a late rejection for the host boot to report. const rejections: unknown[] = [] const onUnhandled = (err: unknown): void => { rejections.push(err) } process.on('unhandledRejection', onUnhandled) try { - const ctx = await boot([]) + const ctx = await boot(configLines) const entry = [...ctx.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-tool-todo') expect(entry?.fiber?.state).toBe(FiberState.FAILED) for (let i = 0; i < 100 && rejections.length === 0; i++) { await new Promise(resolve => setTimeout(resolve, 10)) } - expect(rejections.map(String).join('\n')).toContain('$.allowParallelInProgress missing required value') + expect(rejections.map(String).join('\n')).toContain(failure) } finally { process.off('unhandledRejection', onUnhandled) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e2677ab90..82d484db7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4779,6 +4779,12 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4814,7 +4820,7 @@ importers: version: link:../../ui/user-interaction cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) packages/ui/app-boot: dependencies: