diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index e9afb7ed05..5ac1989116 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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-07-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: de469f18cbddd42249e5d459949b745634b4e399 -2026-07-08-self-referential-cordis-toolset.zh.md: 7fdf438b6c144d152d3e55a07d416db9d5a5c0c6 +2026-07-08-self-referential-cordis-toolset.md: c531b8b7da988b9381f7a1c78f4017977dd321d8 +2026-07-08-self-referential-cordis-toolset.zh.md: ae3ef1170d29cf896d4a5c54a4b23289b981b88f diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index de469f18cb..c531b8b7da 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -54,7 +54,7 @@ Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` ( ### Configuration, rendering, and observability -The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_try`, and `cordis_stop`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, try is `execute` carrying code as `rawInput`, and stop is `delete`. +The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_try`, and `cordis_stop`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, try is `execute` carrying code as `rawInput`, and stop is `delete`. Web conversation rows preserve those generic mechanics while giving the tools the action titles `Inspect`, `Try temporary Plugin`, and `Stop temporary Plugin` plus one shared Cordis accent; the try row retains the shared JavaScript expansion and syntax highlighting. Model-visible ⟺ logged holds with no new session event type: try and stop are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 7fdf438b6c..ae3ef1170d 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -54,7 +54,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 配置、渲染与可观测性 -该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_try` 和 `cordis_stop`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,try 为携带代码 `rawInput` 的 `execute`,stop 为 `delete`。 +该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_try` 和 `cordis_stop`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,try 为携带代码 `rawInput` 的 `execute`,stop 为 `delete`。Web 对话行保留这些通用机制,同时为各工具设置操作标题 `Inspect`、`Try temporary Plugin` 和 `Stop temporary Plugin` 以及统一的 Cordis 强调色;try 行仍使用共用的 JavaScript 展开视图和语法高亮。 「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:try 与 stop 通过已记录的 `tool/call` / `tool/result` 对可见,工具集变化由 schema 在 step 间变化时发出的完整 request header 记录。临时 Plugin 属于进程内存,而非 session 状态:恢复持久化 session 只会重建对话历史,绝不会重新创建它们。 diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts new file mode 100644 index 0000000000..fb15f77ab5 --- /dev/null +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -0,0 +1,130 @@ +// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real +// model through inspect, try, and stop; replay pins the same shipped Web +// composition, durable calls, generic rows, highlighted Plugin source, and +// conversation accessibility tree. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const CORDIS_TOOLS = ['cordis_inspect', 'cordis_try', 'cordis_stop'] as const +const TRY_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }' +const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". ' + + `Then call cordis_try with this exact code: ${JSON.stringify(TRY_CODE)}. ` + + 'Read its returned id and call cordis_stop with that exact id. ' + + 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.' + +function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { + const turnEnd = events.findLast( + (event): event is Extract => event.type === 'turn/end', + ) + const reason = turnEnd?.data.reason + const reasonSummary = reason?.kind === 'error' + ? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status } + : { kind: reason?.kind } + expect(reasonSummary).toEqual({ kind: 'completed' }) + + const calls = events.filter( + (event): event is Extract => event.type === 'tool/call', + ) + expect(calls.map(event => event.data.name)).toEqual(CORDIS_TOOLS) + + const callIds = new Set(calls.map(event => String(event.data.callId))) + const results = events.filter( + (event): event is Extract => + event.type === 'tool/result' && callIds.has(String(event.data.callId)), + ) + expect(results).toHaveLength(CORDIS_TOOLS.length) + expect(results.every(event => !event.data.isError)).toBe(true) +} + +describe('web e2e: Cordis tools use the generic row variants', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + cordisTools: true, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('drives the recorded Cordis lifecycle to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-drive')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + assertCompleteCordisLifecycle(sessionEvents) + await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 200_000) + + it.skipIf(MODE === 'record')('the durable log carries one complete Cordis lifecycle', () => { + assertCompleteCordisLifecycle(sessionEvents) + }) + + it.skipIf(MODE === 'record')('renders Cordis lifecycle titles over the generic row mechanics', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-rows')) + await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + + const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first() + await inspectRow.waitFor({ timeout: 10_000 }) + + const tryRow = page.locator('[data-tool="cordis_try"]').filter({ hasText: 'Try temporary Plugin' }).first() + await tryRow.waitFor({ timeout: 10_000 }) + await tryRow.locator('button[aria-expanded]').click() + await expect.poll(() => tryRow.locator('pre.shiki').textContent(), { timeout: 10_000 }) + .toContain(TRY_CODE) + + const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop temporary Plugin' }).first() + await stopRow.waitFor({ timeout: 10_000 }) + await expect.poll(() => stopRow.textContent()).toContain('dyn-') + await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok') + }) + + it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria')) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean: no page errors or reconnect churn', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 89269d743f..f4797e0076 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -40,6 +40,7 @@ import SessionStore, { type SessionHeader, } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' @@ -122,6 +123,12 @@ export interface LaunchOptions { * insertion is needed. */ toolsMode?: 'native' | 'code' | 'both' + /** + * Insert the opt-in self-referential Cordis tools into the shipped tree. + * Record and replay use the same tool surface, so captured request headers + * remain reconstructable without making the tools a product default. + */ + cordisTools?: boolean } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -174,6 +181,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise console.log('status →', status)) } }"}) [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier @@ -28,8 +30,8 @@ The intended demo is staged — verify the listener link first, then let the age [tool call] cordis_stop({"id": "dyn-1"}) ``` -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference the agent writes plugin code against, and try two cooperating mounts (`ctx.provide` in one, `inject` in the other) to watch cordis park and revive the consumer. +Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and try two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. ## End-to-end tests -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two mounts through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. +`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model tries a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index 346c4ef936..c9991dea88 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者通过 agent(智能体)所在的 **实时 cordis 运行时** 向模型提供三个工具:检查运行时、将新插件挂载到其中,以及再次释放它们。`ctx.fs` 和 `ctx.web` 服务也会挂载(仅作为提供方,不包含面向模型的文件/Web 工具),使 agent 编写的插件可以构建于真实能力之上;Node 内置模块在沙箱中被截获并重定向到这些服务。设计(沙箱语义、挂载生命周期、跨挂载组合、注意事项)详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、尝试仅存于内存的临时 Plugin,并再次停止它们。临时 Plugin 可跨 turn 保持活跃,但会在 stop、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 运行 @@ -10,7 +10,9 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis +pnpm run demo:cordis # TUI (default) +pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 +pnpm run demo:cordis acp # ACP server ``` 预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: @@ -20,7 +22,7 @@ pnpm run demo:cordis [tool call] cordis_try({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier @@ -28,8 +30,8 @@ pnpm run demo:cordis [tool call] cordis_stop({"id": "dyn-1"}) ``` -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看为 agent 生成、供其编写插件时参考的服务/事件资料。还可尝试两个协作挂载(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 cordis 如何暂停并恢复消费方。 +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可尝试两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 ## 端到端测试 -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载状态监听器,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个挂载。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型尝试一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 diff --git a/examples/web-cordis/.gitignore b/examples/web-cordis/.gitignore new file mode 100644 index 0000000000..4da346bc81 --- /dev/null +++ b/examples/web-cordis/.gitignore @@ -0,0 +1,2 @@ +.sessions/ +.storages/ diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml new file mode 100644 index 0000000000..80b598c696 --- /dev/null +++ b/examples/web-cordis/cordis.yml @@ -0,0 +1,18 @@ +# Opt-in Web composition for inspecting the self-referential Cordis tools. +# Temporary Plugin code can reach every injected live capability; treat this +# deployment like shell access, not as a security boundary. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../../apps/cli/cordis.yml + patches: + # AppCLIEntry normally injects this assembly-owned path before `dsh web` + # boots; the standalone Cordis launcher needs the equivalent patch here. + - id: webserver + config: + host: 127.0.0.1 + port: 3081 + distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" + - insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/package.json b/package.json index 90194a57d3..aca03d520c 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml", + "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 56923eff77..e4a41bcc0b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6 -README.zh.md: 88992176165ab11050a30c7df381479796908ba2 +README.md: 4e12b82070bd27a0724cc3e43e952a4221339f49 +README.zh.md: b4936501b68e110ff538fec28bb52a3a72d7b849 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 453922dafd..4e12b82070 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Try temporary Plugin`, and `Stop temporary Plugin` with a shared Cordis accent; try keeps the code variant's expandable source rendering. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8899217616..b4936501b6 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Try temporary Plugin` 和 `Stop temporary Plugin`;try 行保留 code 变体的可展开源码渲染。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 7dbefdc139..5cb2126f34 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -30,6 +30,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr return ( +
= { write: 'write', edit: 'edit', run_code: 'code', + cordis_inspect: 'read', + cordis_try: 'code', + cordis_stop: 'others', +} + +/** Tool-owned titles that refine a generic row variant without replacing it. */ +const TOOL_TITLES: Record = { + cordis_inspect: 'Inspect', + cordis_try: 'Try temporary Plugin', + cordis_stop: 'Stop temporary Plugin', } /** @@ -130,12 +140,15 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool - // name rides the mutable summary slot so no information is lost. - const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base + // name rides the mutable summary slot unless the tool owns a specific title. + const summary = variant === 'others' && toolName !== '' && toolTitle === undefined + ? `${toolName} · ${base}` + : base return { variant, - title: VARIANT_TITLES[variant], + title: toolTitle ?? VARIANT_TITLES[variant], summary, body: deriveBody(variant, argsRaw), state, diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 972cb930d2..003a3e6b13 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -167,6 +167,28 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) + it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => { + const parent = 'call-cordis' + const code = 'return { name: "audit", apply(ctx) {} }' + const dispatches = new Map([[parent, [ + subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'), + subCall(12, parent, 2, 'cordis_try', { code }, 'Temporary Plugin dyn-2 is running'), + subCall(13, parent, 3, 'cordis_stop', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was stopped and removed.'), + ]]]) + const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const view = mountApp(b.slots) + const nest = view.container.querySelector('[data-subcalls]')! + + expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') + const tried = nest.querySelector('[data-variant="code"]') + expect(tried?.textContent).toContain(`Try temporary Plugin${code}`) + expect(nest.querySelector('[data-tool="cordis_stop"]')?.textContent) + .toContain('Stop temporary Plugindyn-2') + + fireEvent.click(tried!.querySelector('button[aria-expanded]')!) + expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code) + }) + it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index a221a4028b..5c0e8d6337 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -31,6 +31,9 @@ describe('tool-call-model', () => { expect(classifyTool('grep')).toBe('search') expect(classifyTool('write')).toBe('write') expect(classifyTool('edit')).toBe('edit') + expect(classifyTool('cordis_inspect')).toBe('read') + expect(classifyTool('cordis_try')).toBe('code') + expect(classifyTool('cordis_stop')).toBe('others') expect(classifyTool('todo_write')).toBe('others') }) @@ -67,6 +70,33 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull() expect(toolRowModel('bash', result({ call: null })).body).toBeNull() }) + + it('gives Cordis lifecycle tools action titles over their generic variants', () => { + expect(toolRowModel('cordis_inspect', running({ + name: 'cordis_inspect', + argsRaw: '{"what":"api","name":"tools"}', + }))).toMatchObject({ + variant: 'read', + title: 'Inspect', + summary: 'api', + }) + expect(toolRowModel('cordis_try', running({ + name: 'cordis_try', + argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}', + }))).toMatchObject({ + variant: 'code', + title: 'Try temporary Plugin', + summary: 'return { name: "audit", apply(ctx) {} }', + body: 'return { name: "audit", apply(ctx) {} }', + }) + expect(toolRowModel('cordis_stop', result({ + call: { name: 'cordis_stop', argsRaw: '{"id":"dyn-2"}' }, + }))).toMatchObject({ + variant: 'others', + title: 'Stop temporary Plugin', + summary: 'dyn-2', + }) + }) }) describe('ToolRow', () => { diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 5495750d23..f1a6c6da17 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -12,7 +12,7 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState, @@ -162,6 +162,25 @@ describe('keyed toolview hole through the real machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) + it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => { + const code = 'return { name: "audit", apply(ctx) {} }' + const b = await bench([ + toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'), + toolResult(4, 'cordis-2', 'cordis_try', JSON.stringify({ code })), + toolResult(5, 'cordis-3', 'cordis_stop', '{"id":"dyn-2"}'), + ]) + const view = mountApp(b.slots) + + expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') + const tried = view.container.querySelector('[data-variant="code"]') + expect(tried?.textContent).toContain(`Try temporary Plugin${code}`) + expect(view.container.querySelector('[data-tool="cordis_stop"]')?.textContent) + .toContain('Stop temporary Plugindyn-2') + + fireEvent.click(tried!.querySelector('button[aria-expanded]')!) + expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code) + }) + it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => { const b = await bench([toolResult(3, 'c1', 'bash')]) const view = mountApp(b.slots) diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs new file mode 100644 index 0000000000..94bbea1332 --- /dev/null +++ b/scripts/demo-cordis.mjs @@ -0,0 +1,24 @@ +/** + * Boot the self-referential Cordis tools under TUI, Web, or ACP, defaulting + * to TUI. This is a repository demo wrapper, not a product CLI feature. + */ +import { spawn } from 'node:child_process' + +const SURFACES = new Map([ + ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/cordis-agent/cordis.yml']], + // `dsh web` does not accept alternate configs yet. The TUI config escape + // hatch still boots this browser-only tree; the config owns port 3081. + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/web-cordis/cordis.yml']], + ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], +]) + +const surface = process.argv[2] ?? 'tui' +const args = SURFACES.get(surface) +if (args === undefined || process.argv.length > 3) { + console.error('usage: pnpm run demo:cordis [tui|web|acp]') + process.exit(2) +} + +if (surface === 'web') console.log('Cordis Web: http://127.0.0.1:3081') +const child = spawn(process.execPath, args, { stdio: 'inherit' }) +child.on('exit', (code, signal) => { process.exit(signal === null ? code ?? 1 : 1) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 2c368e53ed..e856953daa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -20,6 +20,7 @@ "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", + "apps/web/tests/cordis-tool-round.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts",