>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts
new file mode 100644
index 0000000000..528fcd34b7
--- /dev/null
+++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts
@@ -0,0 +1,137 @@
+/**
+ * Projections block on the session.history tail page: a registered fake
+ * provider's whole value rides the tail page with asOfSeq equal to the window
+ * tail seq; loadOlder pages (beforeSeq present) never carry the block; a
+ * composition without the registry serves histories without the block; a
+ * disposed registration's key leaves subsequent responses; and a provider
+ * value rejected by its own schema fails the handler loud.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { z } from 'zod'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import SessionStore from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionProvider } from '@deepseek-ai/dsh-session-projection'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
+
+declare module '@deepseek-ai/dsh-session-projection' {
+ interface SessionProjectionMap {
+ 'test/echo-seq': { seenSeq: number }
+ }
+}
+
+let nextRpc = 1
+function request(payload: P): RpcRequest
{
+ return { rpcId: RpcId(`proj-${String(nextRpc++)}`), payload }
+}
+
+/** Provider whose value records the session seq it observed at get() time. */
+const echoSeqProvider: ProjectionProvider<'test/echo-seq'> = {
+ key: 'test/echo-seq',
+ schema: z.object({ seenSeq: z.number().int().nonnegative() }),
+ get: agent => ({ seenSeq: agent.session.seq }),
+}
+
+async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(UserInteractionService)
+ await ctx.plugin(AgentRegistry)
+ if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
+ const session = ctx.sessions.create()
+ // history resolves the agent first; a live structural stub is enough (only
+ // .session is read on this path).
+ ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+ return { ctx, session }
+}
+
+/** Append `count` user messages so the log has paginable message boundaries. */
+function seedMessages(session: Session, count: number): void {
+ for (let i = 0; i < count; i++) {
+ session.append('user/message', { content: [{ type: 'text', text: `m${i}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ }
+}
+
+describe('session.history projections block', () => {
+ it('serves the registered value on the tail page with asOfSeq = window tail seq', async () => {
+ const { ctx, session } = await harness(true)
+ ctx.sessionProjections.register(echoSeqProvider)
+ seedMessages(session, 3)
+ const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+ const response = await api.sessions.history(request({ sessionId: session.id }))
+ expect(response.result.ok).toBe(true)
+ if (!response.result.ok) throw new Error('unreachable')
+ const { events, projections } = response.result.value
+ expect(projections).toBeDefined()
+ expect(projections?.asOfSeq).toBe(session.seq)
+ // The cut is consistent: the value observed the same seq the block stamps.
+ expect(projections?.values['test/echo-seq']).toEqual({ seenSeq: session.seq })
+ // asOfSeq is the window tail: the last served event sits right below it.
+ expect(events.at(-1)?.event.seq).toBe(session.seq - 1)
+ })
+
+ it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
+ const { ctx, session } = await harness(true)
+ ctx.sessionProjections.register(echoSeqProvider)
+ seedMessages(session, 5)
+ const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+ const older = await api.sessions.history(request({ sessionId: session.id, beforeSeq: 3, maxMessages: 2 }))
+ expect(older.result.ok).toBe(true)
+ if (!older.result.ok) throw new Error('unreachable')
+ expect('projections' in older.result.value).toBe(false)
+ })
+
+ it('serves no block when the composition has no projection registry', async () => {
+ const { ctx, session } = await harness(false)
+ seedMessages(session, 2)
+ const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+ const response = await api.sessions.history(request({ sessionId: session.id }))
+ expect(response.result.ok).toBe(true)
+ if (!response.result.ok) throw new Error('unreachable')
+ expect('projections' in response.result.value).toBe(false)
+ })
+
+ it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => {
+ const { ctx, session } = await harness(true)
+ const dispose = ctx.sessionProjections.register(echoSeqProvider)
+ seedMessages(session, 1)
+ const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+ const before = await api.sessions.history(request({ sessionId: session.id }))
+ if (!before.result.ok) throw new Error('unreachable')
+ expect(before.result.value.projections?.values['test/echo-seq']).toBeDefined()
+
+ dispose()
+ const after = await api.sessions.history(request({ sessionId: session.id }))
+ if (!after.result.ok) throw new Error('unreachable')
+ // The registry is still mounted, so the block itself stays (asOfSeq cut
+ // with zero keys); the disposed key reads as capability absence.
+ expect(after.result.value.projections?.asOfSeq).toBe(session.seq)
+ expect(after.result.value.projections?.values).toEqual({})
+ })
+
+ it('fails loud when a provider value violates its own schema (async get is unrepresentable)', async () => {
+ const { ctx, session } = await harness(true)
+ ctx.sessionProjections.register({
+ key: 'test/echo-seq',
+ schema: z.object({ seenSeq: z.number().int().nonnegative() }),
+ // A Promise (what an accidentally-async get would return) is not the
+ // declared shape: the boundary parse rejects it before it hits the wire.
+ get: () => Promise.resolve({ seenSeq: 0 }) as never,
+ })
+ seedMessages(session, 1)
+ const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+ await expect(api.sessions.history(request({ sessionId: session.id }))).rejects.toThrow()
+ })
+})
diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json
index f5aabb1cf8..4f5d52ed73 100644
--- a/packages/host/apiproxy/tsconfig.json
+++ b/packages/host/apiproxy/tsconfig.json
@@ -32,6 +32,9 @@
{
"path": "../../session-persistence/session-persistence"
},
+ {
+ "path": "../../session-projection/session-projection"
+ },
{
"path": "../../session-title/session-title"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7795770e40..5322fa9a7f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2604,6 +2604,9 @@ importers:
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../../session-persistence/session-persistence
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session-projection/session-projection
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session-title/session-title
From 70cc77eab063476e72777975126cc1f29e7e8aa3 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:11:33 +0800
Subject: [PATCH 10/97] feat: pure-type /types outlet for
dsh-session-projection (client-aggregate import path)
---
packages/host/apiproxy/src/api/sessions.ts | 4 +++-
.../session-projection/package.json | 5 +++++
.../session-projection/src/index.ts | 10 +++-------
.../session-projection/src/types.ts | 17 +++++++++++++++++
tsconfig.base.json | 1 +
5 files changed, 29 insertions(+), 8 deletions(-)
create mode 100644 packages/session-projection/session-projection/src/types.ts
diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts
index 00e2511862..5579e638ee 100644
--- a/packages/host/apiproxy/src/api/sessions.ts
+++ b/packages/host/apiproxy/src/api/sessions.ts
@@ -6,7 +6,9 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
-import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection'
+// The pure-type outlet: api/ is browser-importable, and the package root's
+// cordis Context merge (via dsh-agent) must not enter client aggregates.
+import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
diff --git a/packages/session-projection/session-projection/package.json b/packages/session-projection/session-projection/package.json
index 8066645272..d4da79599d 100644
--- a/packages/session-projection/session-projection/package.json
+++ b/packages/session-projection/session-projection/package.json
@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
+ "./types": {
+ "types": "./lib/types/types.d.ts",
+ "default": "./lib/types/types.js"
+ },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
+ "lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts
index 41d2793166..47f66e98ea 100644
--- a/packages/session-projection/session-projection/src/index.ts
+++ b/packages/session-projection/session-projection/src/index.ts
@@ -25,13 +25,9 @@ declare module 'cordis' {
}
}
-/**
- * The single projection type table for the whole chain (host provider, wire
- * block, client cell, React hook). Domain packages merge their key here via
- * declaration merging; values are wire-JSON whole values. How a value is
- * rendered is the slot system's business, never this layer's.
- */
-export interface SessionProjectionMap {}
+import type { SessionProjectionMap } from './types.ts'
+
+export type { SessionProjectionMap } from './types.ts'
/**
* One domain's host-side contribution: the current whole value of its
diff --git a/packages/session-projection/session-projection/src/types.ts b/packages/session-projection/session-projection/src/types.ts
new file mode 100644
index 0000000000..39f2aa24e2
--- /dev/null
+++ b/packages/session-projection/session-projection/src/types.ts
@@ -0,0 +1,17 @@
+/**
+ * Pure-type outlet of the session-projection seam: the one projection type
+ * table, importable from client aggregates without dragging the host-side
+ * cordis Context merges of the package root (dsh-agent → dsh-session). Domain
+ * packages may declare-merge through either the package root or this outlet —
+ * re-export preserves symbol identity, so both land on the same table.
+ *
+ * @module @deepseek-ai/dsh-session-projection/types
+ */
+
+/**
+ * The single projection type table for the whole chain (host provider, wire
+ * block, client cell, React hook). Domain packages merge their key here via
+ * declaration merging; values are wire-JSON whole values. How a value is
+ * rendered is the slot system's business, never this layer's.
+ */
+export interface SessionProjectionMap {}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 46b9e07203..f2f42116be 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"],
"@deepseek-ai/dsh-session/types": ["./packages/core/session/src/types.ts"],
"@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"],
+ "@deepseek-ai/dsh-session-projection/types": ["./packages/session-projection/session-projection/src/types.ts"],
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
"@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
"@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"],
From 95a3794e6811d307038f7b811838057bb6aa3bc4 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:15:29 +0800
Subject: [PATCH 11/97] refactor(gui): source SessionProjectionMap from the
interface package's pure-type outlet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Swap the client runtime's parallel-construction placeholder for
import type from @deepseek-ai/dsh-session-projection/types — the zero-import
outlet, never the package root, whose dsh-agent → dsh-session chain would drag
the host Context.sessions merge into the client program. One type table end to
end (host provider, wire block, client cell, React hook); the spec's test key
now declare-merges the real module. Adds the workspace dep and the tsconfig
project reference.
---
packages/client/runtime/package.json | 1 +
.../src/client/sessions/projection-cell.ts | 20 ++++++++-----------
.../runtime/tests/projection-cell.spec.ts | 6 +++---
packages/client/runtime/tsconfig.json | 3 +++
4 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json
index 4bd95595c1..ff994dad72 100644
--- a/packages/client/runtime/package.json
+++ b/packages/client/runtime/package.json
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"zustand": "~4.4.7"
diff --git a/packages/client/runtime/src/client/sessions/projection-cell.ts b/packages/client/runtime/src/client/sessions/projection-cell.ts
index 539015992e..de7cb5ac12 100644
--- a/packages/client/runtime/src/client/sessions/projection-cell.ts
+++ b/packages/client/runtime/src/client/sessions/projection-cell.ts
@@ -8,21 +8,17 @@
* (useProjection) happens in web-react.
*/
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from './notifier.ts'
-/**
- * The single projection type table, typed end to end (host provider, wire
- * block, client cell, React hook). Domain packages merge their keys in.
- *
- * TODO(gui): switch to `import type { SessionProjectionMap } from
- * '@deepseek-ai/dsh-session-projection'` (pure type-only edge) once the host
- * interface package lands; this placeholder is structurally identical and
- * exists only because the two bases are built in parallel. No second
- * client-side "views" table — one map end to end (user ruling, RFC
- * Alternatives).
- */
-export interface SessionProjectionMap {}
+// The single projection type table, typed end to end (host provider, wire
+// block, client cell, React hook) — the interface package's pure-type outlet
+// (`/types`, zero imports), never the package root: the root's dsh-agent →
+// dsh-session chain would drag the host `Context.sessions` merge into the
+// client program (one program must not hold both sides). No second
+// client-side "views" table (user ruling, RFC Alternatives).
+export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
/**
* Minimal validating-schema face (zod-compatible: `ZodType` satisfies it
diff --git a/packages/client/runtime/tests/projection-cell.spec.ts b/packages/client/runtime/tests/projection-cell.spec.ts
index 78a661222b..8ab22c4347 100644
--- a/packages/client/runtime/tests/projection-cell.spec.ts
+++ b/packages/client/runtime/tests/projection-cell.spec.ts
@@ -15,9 +15,9 @@ import { SessionsService } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
-// Test-domain key merged into the (placeholder) projection map: a whole-value
-// marker list, the smallest last-wins shape.
-declare module '../src/client/sessions/projection-cell.ts' {
+// Test-domain key merged into the projection map (the interface package's
+// pure-type outlet): a whole-value marker list, the smallest last-wins shape.
+declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'test/marks': { marks: string[] }
}
diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json
index 2e22ea1013..afb8b76cb3 100644
--- a/packages/client/runtime/tsconfig.json
+++ b/packages/client/runtime/tsconfig.json
@@ -23,6 +23,9 @@
{
"path": "../../host/apiproxy"
},
+ {
+ "path": "../../session-projection/session-projection"
+ },
{
"path": "../../llm/llm"
},
From 555a6aa7cc268a1ffd8f735274a2e5cf649c39d7 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:20:26 +0800
Subject: [PATCH 12/97] refactor(gui): read the typed projections block off the
history response
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The wire type now carries projections?: SessionProjectionsBlock (host-base
landed), so the structural projectionsOf narrowing and its TODO(gui) go away —
Session reads result.value.projections directly at all three installWindow
sites. ProjectionsBaseline stays as the cell framework's structural twin
(React-free layer keeps depending on the type table only) with values typed
Partial; the erased walk moves inside resetBaseline
where per-key typing is re-established by schema.parse.
---
.../src/client/sessions/projection-cell.ts | 14 +++++++++---
.../runtime/src/client/sessions/session.ts | 22 +++----------------
.../runtime/tests/projection-cell.spec.ts | 4 +++-
3 files changed, 17 insertions(+), 23 deletions(-)
diff --git a/packages/client/runtime/src/client/sessions/projection-cell.ts b/packages/client/runtime/src/client/sessions/projection-cell.ts
index de7cb5ac12..b4b5204416 100644
--- a/packages/client/runtime/src/client/sessions/projection-cell.ts
+++ b/packages/client/runtime/src/client/sessions/projection-cell.ts
@@ -68,12 +68,17 @@ export type UseProjection = {
): S
}
-/** Tail-page projections baseline (structural wire mirror; the zod schema lands with the host-base PR). */
+/**
+ * Tail-page projections baseline — structurally identical to the wire's
+ * `SessionProjectionsBlock` (apiproxy api layer), restated here so the
+ * React-free cell framework depends only on the type table, not the wire
+ * package's response vocabulary.
+ */
export interface ProjectionsBaseline {
/** The consistent-cut seq (equals the window tail seq by construction). */
asOfSeq: number
/** Whole current values by key; a registered key absent here means the capability is absent. */
- values: Record
+ values: Partial
}
/** Type-erased spec view the framework machinery works with (the register seam already proved the typed contract). */
@@ -215,8 +220,11 @@ export class ProjectionCellSet {
* @param baseline - the response's projections block.
*/
resetBaseline(baseline: ProjectionsBaseline): void {
+ // Erased view: the framework walks the open key space; per-key typing
+ // lives at the cell spec seam (schema.parse re-establishes it).
+ const values = baseline.values as Record
for (const [key, cell] of this.cells) {
- cell.resetBaseline(Object.hasOwn(baseline.values, key), baseline.values[key], baseline.asOfSeq)
+ cell.resetBaseline(Object.hasOwn(values, key), values[key], baseline.asOfSeq)
}
}
}
diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts
index a77197e7e3..ec5c9c6023 100644
--- a/packages/client/runtime/src/client/sessions/session.ts
+++ b/packages/client/runtime/src/client/sessions/session.ts
@@ -495,13 +495,13 @@ export class Session implements ObservableSnapshot {
this.openError = result.error
return
}
- this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value))
+ this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
- if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value))
+ if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections)
}
this.openState = 'open'
} catch (error) {
@@ -590,7 +590,7 @@ export class Session implements ObservableSnapshot {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
- this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value))
+ this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -850,19 +850,3 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
if (hasContent) return 'active'
return promptAttempted ? 'engaging' : 'blank'
}
-
-/**
- * Structural read of the optional projections block on a history response.
- * TODO(gui): drop this narrowing once the host-base PR (dsh-session-projection
- * + apiproxy block) lands and the wire type carries `projections` — parallel
- * construction posture, same as the code-dispatch event narrowing above.
- * @param value - the history response value.
- * @returns the block, or undefined (loadOlder pages and blockless deployments).
- */
-function projectionsOf(value: object): ProjectionsBaseline | undefined {
- const block = (value as { projections?: ProjectionsBaseline }).projections
- if (block === undefined) return undefined
- return typeof block.asOfSeq === 'number' && typeof block.values === 'object' && block.values !== null
- ? block
- : undefined
-}
diff --git a/packages/client/runtime/tests/projection-cell.spec.ts b/packages/client/runtime/tests/projection-cell.spec.ts
index 8ab22c4347..85609d20a7 100644
--- a/packages/client/runtime/tests/projection-cell.spec.ts
+++ b/packages/client/runtime/tests/projection-cell.spec.ts
@@ -95,7 +95,9 @@ describe('ProjectionCellSet semantics', () => {
it('degrades a baseline payload failing schema validation to absent instead of poisoning the cell', () => {
const { set, cell } = bench()
- set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' } })
+ // Deliberately malformed wire payload: the typed block cannot express it,
+ // which is exactly why the boundary schema exists.
+ set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' as never } })
expect(cell.getSnapshot()).toBeUndefined()
// The watermark still advanced to the cut: pre-cut events stay dropped.
set.offerEvent(markEvent(8, ['pre-cut']))
From e900ebd4c67246453854f300636112b7d0aab495 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:33:33 +0800
Subject: [PATCH 13/97] feat: todos session-projection provider in tool-todo
(knife-4 domain probe)
---
.../runtime/tests/projection-todo.spec.ts | 92 +++++++++++++++
packages/todo/tool-todo/README.md | 4 +
packages/todo/tool-todo/package.json | 7 ++
packages/todo/tool-todo/src/index.ts | 52 ++++++++-
.../todo/tool-todo/tests/projection.spec.ts | 106 ++++++++++++++++++
packages/todo/tool-todo/tsconfig.json | 3 +
pnpm-lock.yaml | 16 +++
7 files changed, 278 insertions(+), 2 deletions(-)
create mode 100644 packages/client/runtime/tests/projection-todo.spec.ts
create mode 100644 packages/todo/tool-todo/tests/projection.spec.ts
diff --git a/packages/client/runtime/tests/projection-todo.spec.ts b/packages/client/runtime/tests/projection-todo.spec.ts
new file mode 100644
index 0000000000..8b98a82edf
--- /dev/null
+++ b/packages/client/runtime/tests/projection-todo.spec.ts
@@ -0,0 +1,92 @@
+/**
+ * Knife-4 acceptance probe (session-projection RFC): the todo domain's client
+ * cell — `fromEvent: todo/write ⇒ whole list` — runs end to end on the
+ * UNMODIFIED cell framework: baseline seeding from a history response's
+ * projections block, live last-wins folding, and the seq guard, with the
+ * `todos` key merged test-locally the same way the domain client plugin will
+ * (through the interface package's pure-type outlet). Zero framework edits.
+ */
+import { describe, expect, it } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import type { TodoItem } from '@deepseek-ai/dsh-session/types'
+import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import type { ProjectionCellSpec } from '../src/client/sessions/projection-cell.ts'
+import { Session } from '../src/client/sessions/session.ts'
+import { FakeApiClient, ok } from './fake-api.ts'
+import { entries, plainTurn } from './event-script.ts'
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionMap {
+ todos: TodoItem[] | null
+ }
+}
+
+const SID = 'fk-todo' as SessionId
+
+const todoEvent = (seq: number, todos: TodoItem[]): SessionEvent =>
+ ({ seq, time: 1_700_000_000_000 + seq, type: 'todo/write', data: { todos } }) as unknown as SessionEvent
+
+/** The exact cell the todo domain client plugin will register: whole-list fromEvent, array-or-null schema. */
+const todosSpec = (): ProjectionCellSpec<'todos'> => ({
+ key: 'todos',
+ schema: {
+ parse: (value) => {
+ if (value === null || Array.isArray(value)) return value as TodoItem[] | null
+ throw new Error('not a todos payload')
+ },
+ },
+ fromEvent: event => (event.type === 'todo/write'
+ ? (event as unknown as { data: { todos: TodoItem[] } }).data.todos
+ : undefined),
+})
+
+function makeSession() {
+ const api = new FakeApiClient()
+ const session = new Session(SID, api)
+ session.projections.register(todosSpec())
+ const cell = session.projections.cellOf('todos')
+ if (cell === undefined) throw new Error('cell missing after register')
+ return { api, session, cell }
+}
+
+describe('todo projection cell over the unmodified framework', () => {
+ it('seeds null from a pre-first-write baseline, then a live todo/write replaces it whole', async () => {
+ const { api, session, cell } = makeSession()
+ api.onHistory = () => Promise.resolve(ok({
+ events: entries(plainTurn(0, 0, 'q', 'a')) as never[], hasMore: false,
+ projections: { asOfSeq: 5, values: { todos: null } },
+ } as never))
+ await session.open()
+ expect(cell.getSnapshot()).toBeNull()
+ const list: TodoItem[] = [{ content: 'ship knife 4', status: 'in_progress' }]
+ session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: todoEvent(6, list) })
+ expect(cell.getSnapshot()).toEqual(list)
+ })
+
+ it('seeds the whole list from the baseline and drops a replayed older snapshot (last-wins)', async () => {
+ const { api, session, cell } = makeSession()
+ const current: TodoItem[] = [
+ { content: 'a', status: 'completed' },
+ { content: 'b', status: 'pending' },
+ ]
+ api.onHistory = () => Promise.resolve(ok({
+ events: entries(plainTurn(0, 0, 'q', 'a')) as never[], hasMore: false,
+ projections: { asOfSeq: 9, values: { todos: current } },
+ } as never))
+ await session.open()
+ expect(cell.getSnapshot()).toEqual(current)
+ // A replayed pre-cut write (window path) must not roll the list back.
+ session.projections.offerWindow([todoEvent(4, [{ content: 'stale', status: 'pending' }])])
+ expect(cell.getSnapshot()).toEqual(current)
+ })
+
+ it('reads capability-absent (undefined) when the block omits the todos key', async () => {
+ const { api, session, cell } = makeSession()
+ api.onHistory = () => Promise.resolve(ok({
+ events: entries(plainTurn(0, 0, 'q', 'a')) as never[], hasMore: false,
+ projections: { asOfSeq: 5, values: {} },
+ } as never))
+ await session.open()
+ expect(cell.getSnapshot()).toBeUndefined()
+ })
+})
diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md
index 3615f68953..a44005d002 100644
--- a/packages/todo/tool-todo/README.md
+++ b/packages/todo/tool-todo/README.md
@@ -22,6 +22,10 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) shows it as a persistent plan, and the [web client](../../client/ui-conversation) renders a plan strip plus a dedicated tool row off `ConversationSnapshot.todos` ([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)).
+## Session projection
+
+When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `todos` provider under an injected child: value = the latest `todo/write` snapshot backscanned from the in-memory log tail (whole list, last-wins), `null` before the first write. The key merges into `SessionProjectionMap` here (via the interface package's `/types` outlet); carriers serve it on the history tail page. Compositions without the registry are unaffected.
+
## Export shape
A function/namespace plugin: it exports `name` / `inject` / `apply` and NO default. A stray `export default` would collapse the module via the Loader's `unwrapExports` and drop `inject` (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).
diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json
index 88d5e9b9c9..66d3e66add 100644
--- a/packages/todo/tool-todo/package.json
+++ b/packages/todo/tool-todo/package.json
@@ -26,10 +26,14 @@
"src"
],
"license": "BSD-3-Clause",
+ "dependencies": {
+ "zod": "^4.4.3"
+ },
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
+ "@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -37,11 +41,14 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
+ "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts
index 66b0a8ab12..be7bb8cf65 100644
--- a/packages/todo/tool-todo/src/index.ts
+++ b/packages/todo/tool-todo/src/index.ts
@@ -6,8 +6,24 @@
*/
import type { Context } from 'cordis'
+import { z } from 'zod'
+import type { ZodType } from 'zod'
import { defineTool } from '@deepseek-ai/dsh-tools'
-import type { TodoItem } from '@deepseek-ai/dsh-session'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
+// Type-only: resolves ctx.sessionProjections for the optional provider child.
+import type {} from '@deepseek-ai/dsh-session-projection'
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionMap {
+ /**
+ * The agent's current whole todo list (the latest `todo/write` snapshot),
+ * or `null` before the first write. Whole-value rule: every `todo/write`
+ * carries the complete replacement list, so the fold is last-wins.
+ */
+ todos: TodoItem[] | null
+ }
+}
export const name = 'tool-todo'
export const inject = ['tools']
@@ -57,8 +73,40 @@ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
return todos
}
-/** Register the `todo_write` tool on `ctx.tools`. */
+/** Wire payload schema of the `todos` projection (whole list or pre-first-write null). */
+const todosProjectionSchema: ZodType = z.union([
+ z.array(z.object({
+ content: z.string(),
+ status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
+ })),
+ z.null(),
+])
+
+/**
+ * Current whole todo list: the latest `todo/write` snapshot, backscanned from
+ * the log tail (bounded: first hit terminates; the events live in memory).
+ * `null` = no write yet.
+ */
+function currentTodos(agent: Agent): TodoItem[] | null {
+ const events = agent.session.events
+ for (let i = events.length - 1; i >= 0; i--) {
+ const event = events[i] as SessionEvent
+ if (event.type === 'todo/write') return event.data.todos
+ }
+ return null
+}
+
+/** Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, the `todos` provider. */
export function apply(ctx: Context): void {
+ // The provider child activates only when a projection registry is composed
+ // (headless assemblies without the seam stay unaffected).
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
+ projectionCtx.sessionProjections.register({
+ key: 'todos',
+ schema: todosProjectionSchema,
+ get: currentTodos,
+ })
+ })
ctx.tools.register(defineTool({
name: 'todo_write',
description: DESCRIPTION,
diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts
new file mode 100644
index 0000000000..41e3b30bae
--- /dev/null
+++ b/packages/todo/tool-todo/tests/projection.spec.ts
@@ -0,0 +1,106 @@
+/**
+ * The `todos` projection provider (session-projection RFC knife 4 — the "a
+ * fourth domain is just its own registrations" acceptance probe): mounting
+ * tool-todo beside the registry serves the whole current list on the history
+ * tail page with a consistent asOfSeq; before any write the value is null; a
+ * composition without tool-todo has no `todos` key; unmounting tool-todo
+ * removes it (HMR safety). The carrier and framework are exercised unmodified.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import SessionStore from '@deepseek-ai/dsh-session'
+import type { Session, TodoItem } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
+import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
+
+let nextRpc = 1
+function request(payload: P): RpcRequest
{
+ return { rpcId: RpcId(`todo-proj-${String(nextRpc++)}`), payload }
+}
+
+interface Bench {
+ ctx: Context
+ session: Session
+ tailProjections(): Promise<{ asOfSeq: number; values: Record } | undefined>
+}
+
+async function harness(withTodoTool: boolean): Promise {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SystemPrompt, { persona: '' })
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(UserInteractionService)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
+ if (withTodoTool) await ctx.plugin(ToolTodo)
+ const session = ctx.sessions.create()
+ ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+ const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+ return {
+ ctx,
+ session,
+ async tailProjections() {
+ const response = await api.sessions.history(request({ sessionId: session.id }))
+ if (!response.result.ok) throw new Error('history failed')
+ return response.result.value.projections as { asOfSeq: number; values: Record } | undefined
+ },
+ }
+}
+
+/** One paginable message so the tail page is non-degenerate. */
+function seedMessage(session: Session): void {
+ session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+}
+
+describe('todos projection provider', () => {
+ it('serves null before the first todo/write', async () => {
+ const bench = await harness(true)
+ seedMessage(bench.session)
+ const projections = await bench.tailProjections()
+ expect(projections?.values).toEqual({ todos: null })
+ expect(projections?.asOfSeq).toBe(bench.session.seq)
+ })
+
+ it('serves the latest whole list after writes, asOfSeq = window tail seq', async () => {
+ const bench = await harness(true)
+ const session = bench.session
+ seedMessage(session)
+ const first: TodoItem[] = [{ content: 'a', status: 'pending' }]
+ const second: TodoItem[] = [
+ { content: 'a', status: 'completed' },
+ { content: 'b', status: 'in_progress' },
+ ]
+ session.append('todo/write', { todos: first })
+ session.append('todo/write', { todos: second })
+ const projections = await bench.tailProjections()
+ // Last-wins: the latest snapshot, whole.
+ expect(projections?.values.todos).toEqual(second)
+ expect(projections?.asOfSeq).toBe(session.seq)
+ })
+
+ it('has no todos key when tool-todo is not composed', async () => {
+ const bench = await harness(false)
+ seedMessage(bench.session)
+ const projections = await bench.tailProjections()
+ expect(projections).toBeDefined()
+ expect('todos' in (projections?.values ?? {})).toBe(false)
+ })
+
+ it('drops the key when the tool-todo fiber unloads (HMR safety)', async () => {
+ const bench = await harness(false)
+ seedMessage(bench.session)
+ const fiber = await bench.ctx.plugin(ToolTodo)
+ expect((await bench.tailProjections())?.values).toEqual({ todos: null })
+ await fiber.dispose()
+ expect('todos' in ((await bench.tailProjections())?.values ?? {})).toBe(false)
+ })
+})
diff --git a/packages/todo/tool-todo/tsconfig.json b/packages/todo/tool-todo/tsconfig.json
index f980e5ead1..b35157e58d 100644
--- a/packages/todo/tool-todo/tsconfig.json
+++ b/packages/todo/tool-todo/tsconfig.json
@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
+ {
+ "path": "../../session-projection/session-projection"
+ },
{
"path": "../../support/invariants"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5322fa9a7f..5d037b6a5b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -874,6 +874,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session-projection/session-projection
immer:
specifier: ^10.1.1
version: 10.2.0
@@ -4269,6 +4272,10 @@ importers:
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/todo/tool-todo:
+ dependencies:
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
@@ -4279,6 +4286,9 @@ importers:
'@deepseek-ai/dsh-agent-loop-testkit':
specifier: workspace:^
version: link:../../support/agent-loop-testkit
+ '@deepseek-ai/dsh-host-apiproxy':
+ specifier: workspace:^
+ version: link:../../host/apiproxy
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -4288,12 +4298,18 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session-projection/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
+ '@deepseek-ai/dsh-user-interaction':
+ specifier: workspace:^
+ 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)
From 4e50369eb6b1acb59670f57bb48cc8c2ef0831a7 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:37:41 +0800
Subject: [PATCH 14/97] feat: durable command lifecycle logging in the executor
(command/run + command/done)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CommandService.execute appends the log-only pair around every resolved
handler — run before invocation, done at settlement, including thrown and
aborted handlers (kind:'error'); admission misses log nothing. commandId is
minted monotonically per instance; per-session appends serialize through a
tail queue over SessionStore.appendOutOfBand (zero-step wrap on an idle log,
direct join inside an open turn). The invariant companion now asserts the
pairing relation (unique run ids; a done requires a prior in-log run).
CommandSource is a minimal merge-extensible map (user variant only).
Dependent benches mount SessionStore; TUI/e2e snapshots re-recorded for the
executor's durable-append timing and the /status event counts.
---
.../command-goal/tests/command-goal.spec.ts | 33 +++--
.../plan/plan-mode/tests/plan-mode.spec.ts | 9 +-
packages/ui/commands/README.i18n.yaml | 6 +-
packages/ui/commands/README.md | 3 +-
packages/ui/commands/README.zh.md | 3 +-
packages/ui/commands/package.json | 1 +
packages/ui/commands/src/index.ts | 113 ++++++++++++++++-
packages/ui/commands/src/invariant.ts | 43 +++++--
packages/ui/commands/tests/commands.spec.ts | 119 +++++++++++++++++-
packages/ui/commands/tsconfig.json | 3 +
.../snapshots/disposed-terminal.expected.txt | 74 +++++------
.../snapshots/errors-and-help.expected.txt | 74 +++++------
.../status-diagnostics-narrow.expected.txt | 2 +-
.../snapshots/status-diagnostics.expected.txt | 2 +-
packages/ui/tui/tests/tui.spec.ts | 9 +-
15 files changed, 384 insertions(+), 110 deletions(-)
diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts
index d00a4d7887..cc0f681845 100644
--- a/packages/goal/command-goal/tests/command-goal.spec.ts
+++ b/packages/goal/command-goal/tests/command-goal.spec.ts
@@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
-import { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
+import SessionStore, { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
interface Harness {
@@ -22,8 +22,9 @@ function appendInjection(session: Session, input: UserMessageData): void {
}
/** Build a live idle agent accepted by the exact-identity goal service. */
-function stubAgent(id: string): { agent: Agent; session: Session } {
- const session = new Session(SessionId(id))
+function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
+ // Store-created: the command executor durably logs lifecycle events on it.
+ const session = ctx.sessions.create(SessionId(id))
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
@@ -45,15 +46,31 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
/** Mount the real command registry, goal domain, and producer. */
async function harness(): Promise {
const ctx = new Context()
+ await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const plugin = await ctx.plugin(commandGoal)
- const { agent, session } = stubAgent(`command-goal-${Math.random()}`)
+ const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
ctx.agents.register(agent)
return { ctx, agent, session, plugin }
}
+/** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */
+function domainEvents(session: Session): readonly Session['events'][number][] {
+ const lifecycle = new Set()
+ for (const event of session.events) {
+ if (event.type !== 'command/run' && event.type !== 'command/done') continue
+ lifecycle.add(event.seq)
+ // The zero-step wrap around a lifecycle event is bookkeeping too.
+ const before = session.events[event.seq - 1]
+ const after = session.events[event.seq + 1]
+ if (before?.type === 'turn/start') lifecycle.add(before.seq)
+ if (after?.type === 'turn/end') lifecycle.add(after.seq)
+ }
+ return session.events.filter(event => !lifecycle.has(event.seq))
+}
+
/** Execute `/goal` through the same registry boundary as a UI adapter. */
async function run(test: Harness, suffix = ''): Promise>>> {
const result = await test.ctx.commands.execute(
@@ -98,7 +115,7 @@ describe('/goal human command', () => {
kind: 'success',
text: 'No goal is currently set.\nUsage: /goal [|clear|edit |pause|resume]',
})
- expect(test.session.events).toEqual([])
+ expect(domainEvents(test.session)).toEqual([])
})
it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
@@ -110,14 +127,14 @@ describe('/goal human command', () => {
expect(created.text).toContain('Rounds: 0/256')
expect(created.text).toContain('Activation: armed')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
- expect(test.session.events.map(event => event.type)).toEqual(['user/message'])
+ expect(domainEvents(test.session).map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
- const count = test.session.events.length
+ const count = domainEvents(test.session).length
await expect(run(test, ' replacement')).resolves.toEqual({
kind: 'error',
text: 'A goal is already active. Use /goal edit to change it or /goal clear before replacing it.',
})
- expect(test.session.events).toHaveLength(count)
+ expect(domainEvents(test.session)).toHaveLength(count)
})
it('treats only exact control words as controls', async () => {
diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts
index c5371cba42..bcc88920a8 100644
--- a/packages/plan/plan-mode/tests/plan-mode.spec.ts
+++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
-import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
@@ -24,7 +24,9 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise {
- const session = new Session(SessionId(id))
+ // A live store session when a store is mounted (the command executor logs
+ // lifecycle events through it); bare otherwise (fold/tool-only benches).
+ const session = ctx.get('sessions')?.create(SessionId(id)) ?? new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
@@ -488,6 +490,7 @@ describe('/plan', () => {
expect(bare.get('commands')).toBeUndefined()
const ctx = await setup()
+ await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
await new Promise(resolve => setImmediate(resolve))
@@ -526,6 +529,7 @@ describe('/plan', () => {
it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
const ctx = await setup()
+ await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
await new Promise(resolve => setImmediate(resolve))
const signal = new AbortController().signal
@@ -564,6 +568,7 @@ describe('/plan', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
+ await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await new Promise(resolve => setImmediate(resolve))
diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml
index ac4d257885..57c17b5823 100644
--- a/packages/ui/commands/README.i18n.yaml
+++ b/packages/ui/commands/README.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
-README.md: 8fd49723c4b0534eebd2e590c647caadd63136a7
-README.zh.md: e2ad8ad80d002d769cf6a2c9f4f09c37ce960935
+# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
+README.md: db3d06f395fc50c8a6cf5901e42f0b09e083a07e
+README.zh.md: bb9b9d52c2fd0845b0795c37ba0155de319bae28
diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md
index 8fd49723c4..db3d06f395 100644
--- a/packages/ui/commands/README.md
+++ b/packages/ui/commands/README.md
@@ -8,7 +8,7 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
-`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
+`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the exact line, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
@@ -37,5 +37,4 @@ Registry metadata, command input, and direct output never enter a model request
## Known Limitations and Deferred Work
- **Only unstructured text input** — forms, completion schemas, and typed arguments remain command-owned parsing concerns.
-- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md
index e2ad8ad80d..bb9b9d52c2 100644
--- a/packages/ui/commands/README.zh.md
+++ b/packages/ui/commands/README.zh.md
@@ -8,7 +8,7 @@
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
-`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。
+`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、精确命令行和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
@@ -37,5 +37,4 @@
## 已知限制与延期工作
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
-- **不持久化命令输出**:适配器会实时显示结果,但通用注册表不会将结果加入会话日志,也不会在重新连接后重建结果。
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。
diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json
index 0a777d22d8..6282f9ae08 100644
--- a/packages/ui/commands/package.json
+++ b/packages/ui/commands/package.json
@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
+ "@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts
index ba8a519e4e..99f21ef334 100644
--- a/packages/ui/commands/src/index.ts
+++ b/packages/ui/commands/src/index.ts
@@ -7,11 +7,25 @@ import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
+import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
export const name = 'commands'
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
+/**
+ * Producer record for one command invocation (the `command/run` event's
+ * provenance slot). Merge-extensible sum type mirroring `MessageSourceMap`'s
+ * shape; minimal today because every executor caller is a human-facing UI
+ * surface dispatching a human-typed line, so the sole variant is `user`.
+ */
+export interface CommandSourceMap {
+ user: { kind: 'user' }
+}
+
+/** The union over {@link CommandSourceMap} — who issued a command line. */
+export type CommandSource = CommandSourceMap[keyof CommandSourceMap]
+
/** Immutable metadata for a command's optional unstructured input. */
export interface CommandInputDescriptor {
/** Placeholder shown before the user supplies free-form input. */
@@ -88,6 +102,34 @@ class CommandLayer implements ScopeLayer {
}
}
+declare module '@deepseek-ai/dsh-session' {
+ interface TurnTriggerMap {
+ /** Zero-step turn opened only to durably record a command lifecycle event on an idle log. */
+ command: { kind: 'command' }
+ }
+
+ interface SessionEventMap {
+ /**
+ * A resolved slash command entered its handler. Log-only (never model
+ * surface); paired with `command/done` by `commandId`, mirroring the
+ * `tool/call`↔`tool/result` pairing. `line` is the exact command line as
+ * dispatched.
+ */
+ 'command/run': { commandId: string; name: string; line: string; source: CommandSource }
+ /**
+ * The paired command settled. `kind`/`text` carry the handler's verbatim
+ * outcome (a thrown/aborted handler settles as `kind: 'error'` with the
+ * rendered failure); presentation stays client-computed at render time.
+ */
+ 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
+ }
+
+ interface OutOfBandSessionEventMap {
+ 'command/run': true
+ 'command/done': true
+ }
+}
+
declare module 'cordis' {
interface Context {
commands: CommandService
@@ -225,11 +267,25 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* globals for that agent.
*/
export class CommandService extends Service {
+ /** The executor writes lifecycle events through the session store. */
+ static inject = ['sessions']
+
private readonly layers = new ScopedLayers(
scope => new CommandLayer(scope),
() => { this.notifyChange() },
)
+ /** Monotonic per-instance counter behind {@link mintCommandId}. */
+ private commandSeq = 0
+ /** Instance token keeping minted ids unique across process restarts over one resumed log. */
+ private readonly instanceToken = crypto.randomUUID().slice(0, 8)
+ /**
+ * Per-session lifecycle-append chains: `appendOutOfBand` rejects a second
+ * concurrent out-of-band append, so this service serializes its own writes
+ * (the session-title tail-queue pattern).
+ */
+ private readonly logTails = new WeakMap>()
+
constructor(ctx: Context) {
super(ctx, 'commands')
}
@@ -272,6 +328,15 @@ export class CommandService extends Service {
/**
* Parse and execute a known command without sending it to the model.
+ *
+ * A resolved command's lifecycle is durably logged: `command/run` is
+ * appended before the handler is invoked and `command/done` after
+ * settlement (a thrown or aborted handler settles as `kind: 'error'`).
+ * Admission misses (syntax or unknown name) log nothing — they never
+ * entered a handler. A `command/run` append failure fails the execution
+ * loud; a `command/done` append failure on the handler-failure path is
+ * contained so the handler's own error stays the reported failure.
+ *
* @param agent - exact receiving agent.
* @param line - complete slash-command line.
* @param signal - cancellation signal owned by the UI request.
@@ -287,9 +352,53 @@ export class CommandService extends Service {
const command = this.view(agent).get(parsed.name)
if (command === undefined) return undefined
if (signal.aborted) throw abortError(signal)
+ const commandId = this.mintCommandId()
+ await this.appendLifecycle(agent.session, 'command/run', {
+ commandId, name: parsed.name, line, source: { kind: 'user' },
+ })
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
- const output = command.definition.handler(invocation)
- return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
+ let result: CommandResult
+ try {
+ const output = command.definition.handler(invocation)
+ result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
+ } catch (error: unknown) {
+ try {
+ await this.appendLifecycle(agent.session, 'command/done', {
+ commandId, kind: 'error',
+ text: error instanceof Error ? error.message : renderThrown(error),
+ })
+ } catch (appendError: unknown) {
+ this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`)
+ }
+ throw error
+ }
+ await this.appendLifecycle(agent.session, 'command/done', {
+ commandId, kind: result.kind,
+ ...result.text === undefined ? {} : { text: result.text },
+ })
+ return result
+ }
+
+ /** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
+ private mintCommandId(): string {
+ this.commandSeq += 1
+ return `cmd-${this.instanceToken}-${this.commandSeq}`
+ }
+
+ /**
+ * Append one lifecycle event, serialized per session: `appendOutOfBand`
+ * rejects concurrent out-of-band appends, and two commands may overlap on
+ * one session.
+ */
+ private appendLifecycle(
+ session: Session,
+ type: T,
+ data: SessionEventMap[T],
+ ): Promise> {
+ const tail = this.logTails.get(session) ?? Promise.resolve()
+ const run = tail.then(() => this.ctx.sessions.appendOutOfBand(session, type, data, { kind: 'command' }))
+ this.logTails.set(session, run.then(() => undefined, () => undefined))
+ return run
}
/** Resolve global definitions followed by exact scoped shadows. */
diff --git a/packages/ui/commands/src/invariant.ts b/packages/ui/commands/src/invariant.ts
index 87751d7cb4..858c31591c 100644
--- a/packages/ui/commands/src/invariant.ts
+++ b/packages/ui/commands/src/invariant.ts
@@ -1,11 +1,12 @@
/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-commands`.
+ * Package-owned invariant companion for `@deepseek-ai/dsh-commands`:
+ * command lifecycle events pair by commandId within one session log.
* @module @deepseek-ai/dsh-commands/invariant
*/
-/* jscpd:ignore-start */
import type { Context } from 'cordis'
-import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-commands'
@@ -14,11 +15,36 @@ export const name = 'commands-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
-/**
- * No runtime invariant: registry notifications intentionally hide mutation details and contain
- * observers, so list/find self-comparisons would duplicate implementation rather than detect drift.
- */
-const install: InvariantInstaller = () => {}
+/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
+/** Install pairing validation over loaded logs and newly appended lifecycle events. */
+const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
+ // Install-scoped so a dispose/re-register cycle re-sweeps from a clean slate.
+ const runIds = new WeakMap>()
+ const validateEvent = (session: Session, event: SessionEvent): void => {
+ if (event.type === 'command/run') {
+ const ids = runIds.get(session) ?? new Set()
+ if (ids.has(event.data.commandId)) {
+ fail(`command/run repeats commandId ${JSON.stringify(event.data.commandId)}`)
+ }
+ ids.add(event.data.commandId)
+ runIds.set(session, ids)
+ return
+ }
+ if (event.type !== 'command/done') return
+ if (runIds.get(session)?.has(event.data.commandId) !== true) {
+ fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`)
+ }
+ }
+ for (const session of ctx.sessions.list()) {
+ for (const event of session.events) validateEvent(session, event)
+ }
+ ctx.on('internal/dispatch', (_mode, eventName, args) => {
+ if (eventName !== 'session/event') return
+ const [session, event] = args as [Session, SessionEvent]
+ validateEvent(session, event)
+ }, { global: true })
+}, { inject: ['sessions'] })
+/* jscpd:ignore-end */
/**
* Register this package's invariant companion.
@@ -27,4 +53,3 @@ const install: InvariantInstaller = () => {}
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
-/* jscpd:ignore-end */
diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts
index 7b6fefb2d2..d030b0830b 100644
--- a/packages/ui/commands/tests/commands.spec.ts
+++ b/packages/ui/commands/tests/commands.spec.ts
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { SessionId } from '@deepseek-ai/dsh-session'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
function command(name: string, text = `ran:${name}`): CommandDefinition {
@@ -16,18 +16,27 @@ function command(name: string, text = `ran:${name}`): CommandDefinition {
async function mount(): Promise {
const ctx = new Context()
+ await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
return ctx
}
-/** Mint a scope whose key is sufficient for registry lookup and invocation. */
+/** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
- const agent = { id: name as SessionId } as Agent
+ const session = ctx.sessions.create(SessionId(name))
+ const agent = { id: session.id, session } as Agent
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
return { scope, agent }
}
+/** The lifecycle slice of one agent's log (boundary markers stripped). */
+function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
+ return agent.session.events
+ .filter(event => event.type === 'command/run' || event.type === 'command/done')
+ .map(event => ({ type: event.type, data: event.data }))
+}
+
describe('parseCommand()', () => {
it.each([
['/goal', { name: 'goal', rawInput: '' }],
@@ -286,6 +295,110 @@ describe('CommandService', () => {
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
})
+ it('logs a paired command/run + command/done around a successful handler', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register(command('deploy', 'deployed'))
+
+ await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
+
+ const lifecycle = lifecycleOf(agent)
+ expect(lifecycle).toMatchObject([
+ { type: 'command/run', data: { name: 'deploy', line: '/deploy now', source: { kind: 'user' } } },
+ { type: 'command/done', data: { kind: 'success', text: 'deployed' } },
+ ])
+ const [run, done] = lifecycle as [{ data: { commandId: string } }, { data: { commandId: string } }]
+ expect(run.data.commandId).toBe(done.data.commandId)
+ // Zero-step wrap: the pair stays turn-enclosed on an idle log.
+ expect(agent.session.events.map(event => event.type)).toEqual([
+ 'turn/start', 'command/run', 'turn/end',
+ 'turn/start', 'command/done', 'turn/end',
+ ])
+ })
+
+ it('mints distinct monotonic commandIds across executions', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register(command('first'))
+ ctx.commands.register(command('second'))
+ await ctx.commands.execute(agent, '/first', new AbortController().signal)
+ await ctx.commands.execute(agent, '/second', new AbortController().signal)
+ const ids = lifecycleOf(agent)
+ .filter(event => event.type === 'command/run')
+ .map(event => (event.data as { commandId: string }).commandId)
+ expect(new Set(ids).size).toBe(2)
+ })
+
+ it('logs command/done kind error for an expected error result', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
+ await ctx.commands.execute(agent, '/denied', new AbortController().signal)
+ expect(lifecycleOf(agent)).toMatchObject([
+ { type: 'command/run', data: { name: 'denied' } },
+ { type: 'command/done', data: { kind: 'error', text: 'not now' } },
+ ])
+ })
+
+ it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register({
+ name: 'boom',
+ description: 'Throw',
+ handler: () => { throw new Error('handler exploded') },
+ })
+ await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal))
+ .rejects.toThrow('handler exploded')
+ expect(lifecycleOf(agent)).toMatchObject([
+ { type: 'command/run', data: { name: 'boom' } },
+ { type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
+ ])
+ })
+
+ it('logs command/done kind error when the signal aborts a hanging handler', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register({
+ name: 'hang',
+ description: 'Hang',
+ handler: () => new Promise(() => undefined),
+ })
+ const controller = new AbortController()
+ const pending = ctx.commands.execute(agent, '/hang', controller.signal)
+ // The run append must land before the abort so the pair stays complete.
+ await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
+ controller.abort('operator cancelled command')
+ await expect(pending).rejects.toThrow('operator cancelled command')
+ await vi.waitFor(() => {
+ expect(lifecycleOf(agent)).toMatchObject([
+ { type: 'command/run', data: { name: 'hang' } },
+ { type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
+ ])
+ })
+ })
+
+ it('logs nothing for admission misses (syntax or unknown name)', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register(command('real'))
+ const signal = new AbortController().signal
+ await ctx.commands.execute(agent, 'not a command', signal)
+ await ctx.commands.execute(agent, '/missing', signal)
+ expect(agent.session.events).toEqual([])
+ })
+
+ it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
+ const ctx = await mount()
+ const { agent } = await mintAgentScope(ctx, 'a')
+ ctx.commands.register(command('mid'))
+ agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ await ctx.commands.execute(agent, '/mid', new AbortController().signal)
+ expect(agent.session.events.map(event => event.type)).toEqual([
+ 'turn/start', 'command/run', 'command/done',
+ ])
+ })
+
it.each([
[undefined, /CommandResult/],
[null, /CommandResult/],
diff --git a/packages/ui/commands/tsconfig.json b/packages/ui/commands/tsconfig.json
index 8f0448250f..470acd72df 100644
--- a/packages/ui/commands/tsconfig.json
+++ b/packages/ui/commands/tsconfig.json
@@ -20,6 +20,9 @@
{
"path": "../../core/scope"
},
+ {
+ "path": "../../core/session"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
index b6fb49135b..7d2c06da75 100644
--- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
+++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
@@ -11,46 +11,46 @@ buffer
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3|
-4| " Keyboard shortcuts "
- style 1-18 fg=bright-blue bold
-5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
- style 1-61 fg=bright-black
-6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
- style 1-75 fg=bright-black
-7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
- style 1-73 fg=bright-black
-8| " "
-9| " /clear — Clear the transcript view (session history is unchanged) "
- style 1-65 fg=bright-black
-10| " /exit — Exit after the active turn reaches idle "
- style 1-47 fg=bright-black
-11| " /help — Show keyboard shortcuts and commands "
- style 1-44 fg=bright-black
-12| " /model [[provider/]model] — Show or switch this session's model "
- style 1-63 fg=bright-black
-13| " /reasoning — Toggle reasoning blocks "
- style 1-36 fg=bright-black
-14| " /redraw — Invalidate components and redraw the terminal "
- style 1-55 fg=bright-black
-15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
- style 1-88 fg=bright-black
-16| " /resume — List this workspace's resumable sessions "
- style 1-50 fg=bright-black
-17| " /status — Show detailed session diagnostics "
- style 1-43 fg=bright-black
-18| " /tools — Expand or collapse all tool cards "
- style 1-42 fg=bright-black
-19| " /skill: [instructions] — load a skill into the conversation "
- style 1-65 fg=bright-black
-20|
-21| " provider stream failed after partial output "
+4| " provider stream failed after partial output "
style 1-43 fg=red
-22|
-23| " The previous process ended during this turn. "
+5|
+6| " The previous process ended during this turn. "
style 1-44 fg=yellow
-24|
-25| " Unknown command: /unknown-advanced-command "
+7|
+8| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
+9|
+10| " Keyboard shortcuts "
+ style 1-18 fg=bright-blue bold
+11| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
+ style 1-61 fg=bright-black
+12| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
+ style 1-75 fg=bright-black
+13| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
+ style 1-73 fg=bright-black
+14| " "
+15| " /clear — Clear the transcript view (session history is unchanged) "
+ style 1-65 fg=bright-black
+16| " /exit — Exit after the active turn reaches idle "
+ style 1-47 fg=bright-black
+17| " /help — Show keyboard shortcuts and commands "
+ style 1-44 fg=bright-black
+18| " /model [[provider/]model] — Show or switch this session's model "
+ style 1-63 fg=bright-black
+19| " /reasoning — Toggle reasoning blocks "
+ style 1-36 fg=bright-black
+20| " /redraw — Invalidate components and redraw the terminal "
+ style 1-55 fg=bright-black
+21| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
+ style 1-88 fg=bright-black
+22| " /resume — List this workspace's resumable sessions "
+ style 1-50 fg=bright-black
+23| " /status — Show detailed session diagnostics "
+ style 1-43 fg=bright-black
+24| " /tools — Expand or collapse all tool cards "
+ style 1-42 fg=bright-black
+25| " /skill: [instructions] — load a skill into the conversation "
+ style 1-65 fg=bright-black
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "
diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
index 05c35e32e1..524c620b2e 100644
--- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
+++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
@@ -11,46 +11,46 @@ buffer
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3|
-4| " Keyboard shortcuts "
- style 1-18 fg=bright-blue bold
-5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
- style 1-61 fg=bright-black
-6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
- style 1-75 fg=bright-black
-7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
- style 1-73 fg=bright-black
-8| " "
-9| " /clear — Clear the transcript view (session history is unchanged) "
- style 1-65 fg=bright-black
-10| " /exit — Exit after the active turn reaches idle "
- style 1-47 fg=bright-black
-11| " /help — Show keyboard shortcuts and commands "
- style 1-44 fg=bright-black
-12| " /model [[provider/]model] — Show or switch this session's model "
- style 1-63 fg=bright-black
-13| " /reasoning — Toggle reasoning blocks "
- style 1-36 fg=bright-black
-14| " /redraw — Invalidate components and redraw the terminal "
- style 1-55 fg=bright-black
-15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
- style 1-88 fg=bright-black
-16| " /resume — List this workspace's resumable sessions "
- style 1-50 fg=bright-black
-17| " /status — Show detailed session diagnostics "
- style 1-43 fg=bright-black
-18| " /tools — Expand or collapse all tool cards "
- style 1-42 fg=bright-black
-19| " /skill: [instructions] — load a skill into the conversation "
- style 1-65 fg=bright-black
-20|
-21| " provider stream failed after partial output "
+4| " provider stream failed after partial output "
style 1-43 fg=red
-22|
-23| " The previous process ended during this turn. "
+5|
+6| " The previous process ended during this turn. "
style 1-44 fg=yellow
-24|
-25| " Unknown command: /unknown-advanced-command "
+7|
+8| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
+9|
+10| " Keyboard shortcuts "
+ style 1-18 fg=bright-blue bold
+11| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
+ style 1-61 fg=bright-black
+12| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
+ style 1-75 fg=bright-black
+13| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
+ style 1-73 fg=bright-black
+14| " "
+15| " /clear — Clear the transcript view (session history is unchanged) "
+ style 1-65 fg=bright-black
+16| " /exit — Exit after the active turn reaches idle "
+ style 1-47 fg=bright-black
+17| " /help — Show keyboard shortcuts and commands "
+ style 1-44 fg=bright-black
+18| " /model [[provider/]model] — Show or switch this session's model "
+ style 1-63 fg=bright-black
+19| " /reasoning — Toggle reasoning blocks "
+ style 1-36 fg=bright-black
+20| " /redraw — Invalidate components and redraw the terminal "
+ style 1-55 fg=bright-black
+21| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
+ style 1-88 fg=bright-black
+22| " /resume — List this workspace's resumable sessions "
+ style 1-50 fg=bright-black
+23| " /status — Show detailed session diagnostics "
+ style 1-43 fg=bright-black
+24| " /tools — Expand or collapse all tool cards "
+ style 1-42 fg=bright-black
+25| " /skill: [instructions] — load a skill into the conversation "
+ style 1-65 fg=bright-black
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "
diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
index 4937592cc7..319a314c62 100644
--- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
+++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
@@ -52,7 +52,7 @@ buffer
18| "│ │"
style 0-0 dim
style 55-55 dim
-19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
+19| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
index 915d4e58ef..3be6e24253 100644
--- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
+++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
@@ -49,7 +49,7 @@ buffer
17| "│ │"
style 0-0 dim
style 81-81 dim
-18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
+18| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts
index 232ef112d0..2424a409b6 100644
--- a/packages/ui/tui/tests/tui.spec.ts
+++ b/packages/ui/tui/tests/tui.spec.ts
@@ -1281,6 +1281,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
result.terminal.send('/clear')
result.terminal.send('\r')
+ await tick() // the executor logs command/run durably before the handler clears
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
await tick()
expect(result.terminal.output).toContain('answer after clear')
@@ -1758,7 +1759,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('/workspace/status')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('hidden)')
- expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls')
+ // 6 domain events + the /status invocation's own command/run (open turn: joined directly).
+ expect(result.terminal.output).toContain('running · 7 events · 1 turn · 1 step · 2 tool calls')
expect(result.terminal.output).toContain('1,250 input + 340 output')
expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)')
@@ -1794,7 +1796,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('untitled')
expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)')
- expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls')
+ // An empty log gains the /status invocation's zero-step wrap: turn/start + command/run + turn/end.
+ expect(result.terminal.output).toContain('idle · 3 events · 1 turn · 0 steps · 0 tool calls')
expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
expect(result.terminal.output).toContain('7 used · capacity unknown')
expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC')
@@ -1834,8 +1837,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
for (const command of ['/clear', '/wat']) {
result.terminal.send(command)
result.terminal.send('\r')
+ await tick() // /clear's handler runs after the durable command/run append; keep it from wiping the next notice
}
- await tick()
result.terminal.send('draft')
result.terminal.send('\x03')
result.terminal.send('\x04')
From ba928c5517d0c9a9c0fb50fe1f4bb11e8f2f2bbf Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:38:03 +0800
Subject: [PATCH 15/97] feat(gui): generic command flow node and the
conversation.chat.commandview keyed slot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The FoldAdapter folds the log-only command/run + command/done pair (paired
by commandId) into a CommandNode outside the surface fold and merges the
nodes into the flow by seq; cross-window cuts soft-fall like tool pairs (a
done-only window builds the node from the done, a run with no done renders
as still executing). ChatView renders command nodes through the new keyed
'conversation.chat.commandview' hole (key = command name) with
GenericCommandCard — a stripped-down GenericToolCard showing the command
line and outcome text — as the render-site fallback, so any slash command
renders durably with zero registration and survives refresh, other tabs,
and resume via the mux-broadcast events.
---
packages/client/runtime/src/client/index.ts | 2 +-
.../src/client/sessions/conversation.ts | 26 +++++++
.../src/client/sessions/fold-adapter.ts | 64 ++++++++++++++++-
packages/client/runtime/tests/event-script.ts | 4 ++
packages/client/runtime/tests/fake-api.ts | 4 +-
.../client/runtime/tests/fold-adapter.spec.ts | 71 +++++++++++++++++++
packages/client/runtime/tests/session.spec.ts | 22 ++++++
.../ui-conversation/src/client/apply.ts | 5 +-
.../src/client/chat/ChatView.tsx | 24 ++++++-
.../src/client/chat/GenericCommandCard.tsx | 35 +++++++++
.../src/client/contract/slots.ts | 30 +++++++-
.../ui-conversation/src/client/index.ts | 3 +-
.../ui-conversation/tests/chat-view.spec.tsx | 39 +++++++++-
13 files changed, 316 insertions(+), 13 deletions(-)
create mode 100644 packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts
index c0ad31492d..3b1d11140a 100644
--- a/packages/client/runtime/src/client/index.ts
+++ b/packages/client/runtime/src/client/index.ts
@@ -29,7 +29,7 @@ export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
- AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
+ AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts
index 8cd57c4eb3..16ba778009 100644
--- a/packages/client/runtime/src/client/sessions/conversation.ts
+++ b/packages/client/runtime/src/client/sessions/conversation.ts
@@ -120,6 +120,31 @@ export interface UnknownSurfaceNode {
data: unknown
}
+/**
+ * One slash-command lifecycle folded from the log-only `command/run` /
+ * `command/done` pair (paired by commandId, mirroring tool call↔result).
+ * Log-only events never enter the surface fold, so the FoldAdapter indexes
+ * them separately and merges the nodes into the flow by seq. A window cut
+ * between the pair soft-falls like tool pairs: a done with no in-window run
+ * still builds a node (name/line null), and a run with no done renders as
+ * still executing.
+ */
+export interface CommandNode {
+ kind: 'command'
+ /** Seq of the command/run event; the done event's seq when only the done is in-window. */
+ seq: number
+ /** Unix epoch ms of the anchoring event. */
+ time: number
+ /** Pairing id minted by the host executor. */
+ commandId: string
+ /** Command name (run payload); null when the run fell outside the window. */
+ name: string | null
+ /** Exact dispatched command line (run payload); null when the run fell outside the window. */
+ line: string | null
+ /** Settlement outcome (done payload); null while the command is still executing. */
+ outcome: { kind: 'success' | 'error'; text?: string } | null
+}
+
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
@@ -127,6 +152,7 @@ export type ConversationNode =
| SteeringMessageNode
| ContextMessageNode
| ToolResultNode
+ | CommandNode
| UnknownSurfaceNode
/**
diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts
index d72c1af8e3..8f4b09d72a 100644
--- a/packages/client/runtime/src/client/sessions/fold-adapter.ts
+++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts
@@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
-import type { ConversationNode } from './conversation.ts'
+import type { CommandNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
@@ -99,6 +99,15 @@ export class FoldAdapter {
private callIdx = new Map()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map()
+ /**
+ * Command lifecycle nodes by commandId (insertion = run order). The
+ * `command/run`/`command/done` pair is log-only, so the surface fold never
+ * emits it; this index folds the pair (done settles its run's node in
+ * place) and nodes() merges the products into the flow by seq. Window cuts
+ * soft-fall like tool pairs: a done with no in-window run still builds a
+ * node.
+ */
+ private commandIdx = new Map()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
@@ -128,10 +137,14 @@ export class FoldAdapter {
this.degraded = false
this.callIdx = new Map()
this.resultViews.clear()
+ this.commandIdx = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
- if (event !== undefined) this.indexCall(event, views?.[i])
+ if (event !== undefined) {
+ this.indexCall(event, views?.[i])
+ this.indexCommand(event)
+ }
}
}
@@ -145,6 +158,7 @@ export class FoldAdapter {
this.rev++
this.padded.push(event)
this.indexCall(event, view)
+ this.indexCommand(event)
}
/**
@@ -180,7 +194,21 @@ export class FoldAdapter {
this.nodeCache.set(seq, node)
out.push(node)
}
- const value = { nodes: out, degraded: this.degraded }
+ // Command nodes fold outside the surface (log-only events); merge by seq.
+ // Both inputs are seq-ascending (surface order and run-index insertion
+ // order share the log order), so one linear merge keeps flow order.
+ let nodes = out
+ if (this.commandIdx.size > 0) {
+ nodes = []
+ const commands = [...this.commandIdx.values()]
+ let next = 0
+ for (const node of out) {
+ while (next < commands.length && commands[next]!.seq < node.seq) nodes.push(commands[next++]!)
+ nodes.push(node)
+ }
+ while (next < commands.length) nodes.push(commands[next++]!)
+ }
+ const value = { nodes, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
@@ -195,6 +223,36 @@ export class FoldAdapter {
return seqs
}
+ /** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
+ private indexCommand(event: SessionEvent): void {
+ // Log-only plugin events: the host-side dsh-commands declaration cannot
+ // enter the client program, so this wire consumer narrows structurally
+ // (the same posture as tool/code-dispatch in session.ts).
+ if ((event.type as string) === 'command/run') {
+ const data = event.data as unknown as { commandId: string; name: string; line: string }
+ this.commandIdx.set(data.commandId, {
+ kind: 'command', seq: event.seq, time: event.time,
+ commandId: data.commandId, name: data.name, line: data.line, outcome: null,
+ })
+ return
+ }
+ if ((event.type as string) !== 'command/done') return
+ const data = event.data as unknown as { commandId: string; kind: 'success' | 'error'; text?: string }
+ const run = this.commandIdx.get(data.commandId)
+ const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
+ if (run === undefined) {
+ // Cross-window cut: the run page fell out of the window — build the
+ // node from the done alone (same soft-fall as a call-less tool result).
+ this.commandIdx.set(data.commandId, {
+ kind: 'command', seq: event.seq, time: event.time,
+ commandId: data.commandId, name: null, line: null, outcome,
+ })
+ return
+ }
+ // Settle in place: a fresh node object (published references stay immutable).
+ this.commandIdx.set(data.commandId, { ...run, outcome })
+ }
+
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts
index ada8550136..8611a94f8e 100644
--- a/packages/client/runtime/tests/event-script.ts
+++ b/packages/client/runtime/tests/event-script.ts
@@ -42,6 +42,10 @@ export const ev = {
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
at(seq, { type: 'todo/write', data: { todos } }),
+ commandRun: (seq: number, commandId: string, name: string, line: string): SessionEvent =>
+ at(seq, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } }),
+ commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
+ at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts
index 6987d82d7a..5d2b6d96d9 100644
--- a/packages/client/runtime/tests/fake-api.ts
+++ b/packages/client/runtime/tests/fake-api.ts
@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
- ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
+ ClientResponse, CommandDescriptor, HostFrame, IApiClient, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -119,7 +119,7 @@ export class FakeApiClient implements IApiClient {
// skill lists without casts.
onCommandList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ commands: [] }))
- onCommandExecute: (payload: unknown) => Promise>
+ onCommandExecute: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ skills: [] }))
diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts
index bb360e2a67..4a9bc4c4d1 100644
--- a/packages/client/runtime/tests/fold-adapter.spec.ts
+++ b/packages/client/runtime/tests/fold-adapter.spec.ts
@@ -142,4 +142,75 @@ describe('FoldAdapter', () => {
const node = adapter.nodes().nodes[0]
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
})
+
+ describe('command lifecycle nodes', () => {
+ it('folds a run/done pair into one settled node merged into flow order by seq', () => {
+ const adapter = new FoldAdapter()
+ adapter.reset([
+ ev.user(0, '先说话'),
+ ev.commandRun(1, 'cmd-1', 'plan', '/plan'),
+ ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
+ ev.assistant(3, 0, '然后回答'),
+ ], 0)
+ const { nodes } = adapter.nodes()
+ expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
+ expect(nodes[1]).toMatchObject({
+ kind: 'command', commandId: 'cmd-1', name: 'plan', line: '/plan',
+ outcome: { kind: 'success', text: '已进入 plan mode' },
+ })
+ })
+
+ it('renders a run with no done as still executing (outcome null)', () => {
+ const adapter = new FoldAdapter()
+ adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', '/goal ship it')], 0)
+ expect(adapter.nodes().nodes[0]).toMatchObject({
+ kind: 'command', name: 'goal', line: '/goal ship it', outcome: null,
+ })
+ })
+
+ it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
+ const adapter = new FoldAdapter()
+ adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
+ expect(adapter.nodes().nodes[0]).toMatchObject({
+ kind: 'command', seq: 80, commandId: 'cmd-3', name: null, line: null,
+ outcome: { kind: 'error', text: '失败了' },
+ })
+ })
+
+ it('settles a live-appended done in place, keeping the node at the run seq', () => {
+ const adapter = new FoldAdapter()
+ adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
+ adapter.append(ev.commandRun(6, 'cmd-4', 'clear', '/clear'))
+ const running = adapter.nodes().nodes.find(n => n.kind === 'command')
+ expect(running).toMatchObject({ outcome: null })
+ adapter.append(ev.commandDone(7, 'cmd-4'))
+ const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
+ expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
+ // Settlement replaced the node object rather than mutating the published one.
+ expect(settled).not.toBe(running)
+ })
+
+ it('tails command nodes whose seq is past every surface node', () => {
+ const adapter = new FoldAdapter()
+ adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan', '/plan')], 0)
+ expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
+ })
+
+ it('command nodes survive the degraded linear-scan branch', () => {
+ const adapter = new FoldAdapter()
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ try {
+ adapter.reset([
+ ev.commandRun(0, 'cmd-5', 'plan', '/plan'),
+ ev.commandDone(1, 'cmd-5'),
+ at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
+ ], 0)
+ const { nodes, degraded } = adapter.nodes()
+ expect(degraded).toBe(true)
+ expect(nodes.some(n => n.kind === 'command')).toBe(true)
+ } finally {
+ errorSpy.mockRestore()
+ }
+ })
+ })
})
diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts
index 6c223ef58b..8a7cf0b1c1 100644
--- a/packages/client/runtime/tests/session.spec.ts
+++ b/packages/client/runtime/tests/session.spec.ts
@@ -99,6 +99,28 @@ describe('live event path', () => {
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
+ it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
+ // Live path: run mints an executing node, done settles it in the flow.
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+ feed(ev.commandRun(6, 'cmd-live', 'plan', '/plan'))
+ let command = session.getSnapshot().nodes.at(-1)
+ expect(command).toMatchObject({ kind: 'command', name: 'plan', line: '/plan', outcome: null })
+ feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
+ command = session.getSnapshot().nodes.at(-1)
+ expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
+
+ // Replay path (refresh): the same pair inside the history window folds identically.
+ const replayed = await opened([
+ ...plainTurn(0, 0, 'a', 'b'),
+ ev.commandRun(6, 'cmd-live', 'plan', '/plan'),
+ ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
+ ])
+ expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
+ })
+ })
+
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 789f86aeb6..181dd77cfa 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -156,7 +156,10 @@ export function apply(ctx: Context): void {
id: 'chat',
order: 0,
label: 'Chat',
- children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
+ children: {
+ 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
+ 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
+ },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => {
const scoped = scopedConversation(sessions, sessionId)
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index f61c6da6ef..0d1e53222c 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -20,7 +20,7 @@ import {
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
- CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
+ CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -28,6 +28,7 @@ import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { SelectionTarget } from '../contract/views.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
+import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
@@ -149,6 +150,24 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
)
})
+/** One command lifecycle row: keyed dispatch on the command name with the
+ * generic card as the render-site fallback (zero registration required). A
+ * run-less cross-window node has no name and always lands on the fallback. */
+const CommandRow = memo(function CommandRow({ renderSlot, node }: {
+ renderSlot: RenderToolRow
+ node: CommandNode
+}) {
+ const owner = useMemo(() => ({ node }), [node])
+ return (
+
+ {renderSlot('conversation.chat.commandview', owner, {
+ entryKey: node.name ?? '',
+ fallback: ,
+ })}
+
+ )
+})
+
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
@@ -275,6 +294,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
if (node.kind === 'assistant') {
return
}
+ if (node.kind === 'command') {
+ return
+ }
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return
diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
new file mode 100644
index 0000000000..c177742975
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
@@ -0,0 +1,35 @@
+// GenericCommandCard: the default command row — a stripped-down
+// GenericToolCard rendering the dispatched command line and the settlement
+// text. Supplied by the chat view as the keyed commandview slot's render-site
+// fallback (an unregistered command name lands here); registrants may compose
+// it as a base, feeding the same owner payload through.
+
+import { ToolRow } from './ToolRow.tsx'
+import type { ToolRowState } from '../contract/tool-call-model.ts'
+import type { CommandRowOwnerProps } from '../contract/slots.ts'
+import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+
+/** Node state → row state semantic (running while unsettled; outcome kind after). */
+function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
+ if (outcome === null) return 'running'
+ return outcome.kind === 'error' ? 'error' : 'ok'
+}
+
+export function GenericCommandCard({ node }: CommandRowOwnerProps) {
+ const text = node.outcome?.text
+ const summary = node.outcome === null
+ ? '执行中…'
+ : text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
+ return (
+ }
+ // A cross-window node whose run page fell out of the window has no line.
+ title={node.line ?? '命令'}
+ summary={summary}
+ // Expandable only when the outcome text overflows a one-line summary.
+ body={text !== undefined && text.includes('\n') ? text : null}
+ state={stateOf(node.outcome)}
+ />
+ )
+}
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 44f337d61c..27f8c982f5 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
import type {
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
-import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { CommandNode, ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
@@ -33,6 +33,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
+ /**
+ * The chat view's per-command row hole: keyed dispatch on the command
+ * name (`command/run.name`; a run-less cross-window node has none and
+ * always lands on the fallback). Declared by the chat view entry; the
+ * render site dispatches via `entryKey: name` with GenericCommandCard as
+ * the `fallback` — a slash command renders durably with zero
+ * registration, and a domain upgrades by registering one row component.
+ */
+ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
@@ -156,6 +165,21 @@ export interface ToolRowOwnerProps {
*/
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
+/**
+ * Owner share of the per-command row slot: the frozen {@link CommandNode}
+ * slice off the snapshot (cache-stable reference — memo premise). The node
+ * carries the whole lifecycle (line, pairing id, outcome-or-executing), so a
+ * registrant needs no second data channel; domain state arrives through its
+ * own projection cell.
+ */
+export interface CommandRowOwnerProps {
+ /** Folded command lifecycle node (run + optional done). */
+ node: CommandNode
+}
+
+/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
+export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
+
/**
* Base props of a conversation view entry: the framework standard kit for the
* session-scope 'conversation.view' slot (useSession narrowed to the
@@ -279,9 +303,9 @@ export interface ChatViewInjected {
loadOlder: () => void
}
-/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
+/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
export type ChatViewSlotProps =
- PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
+ PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
& PropsStore & ChatViewInjected
/**
diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts
index 76af2f431c..1b85c52abb 100644
--- a/packages/client/ui-conversation/src/client/index.ts
+++ b/packages/client/ui-conversation/src/client/index.ts
@@ -13,7 +13,8 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
- ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerBarInjected, ComposerChainProps, ConversationInjected,
+ ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
+ ComposerChainProps, ConversationInjected,
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index 80592aa21a..4a9c27d9e5 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
- AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
+ AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -363,4 +363,41 @@ describe('ChatView', () => {
const view = render()
expect(view.getByText(/等待审批/)).toBeTruthy()
})
+
+ it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
+ const command = (over: Partial): CommandNode => ({
+ kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1',
+ name: 'plan', line: '/plan', outcome: { kind: 'success', text: '已进入 plan mode' },
+ ...over,
+ })
+ // Settled success: the command line is the title, the outcome text the summary.
+ const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
+ const view = render()
+ expect(view.getByText('/plan')).toBeTruthy()
+ expect(view.getByText('已进入 plan mode')).toBeTruthy()
+
+ // Error outcome flips the row state; a text-less error gets the default copy.
+ const failed = makeHarness({
+ nodes: [command({ seq: 6, commandId: 'cmd-2', outcome: { kind: 'error' } })],
+ })
+ const fv = render()
+ expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
+ expect(fv.getByText('命令失败')).toBeTruthy()
+
+ // Still executing: running state with the executing copy.
+ const executing = makeHarness({
+ nodes: [command({ seq: 7, commandId: 'cmd-3', outcome: null })],
+ })
+ const xv = render()
+ expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
+ expect(xv.getByText('执行中…')).toBeTruthy()
+
+ // Cross-window soft-fall (run page truncated): generic title, outcome preserved.
+ const orphan = makeHarness({
+ nodes: [command({ seq: 8, commandId: 'cmd-4', name: null, line: null, outcome: { kind: 'success' } })],
+ })
+ const ov = render()
+ expect(ov.getByText('命令')).toBeTruthy()
+ expect(ov.getByText('已完成')).toBeTruthy()
+ })
})
From 0fabbd72ad8f4bbb5e453642b8052daa774e6000 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 27 Jul 2026 23:05:21 +0800
Subject: [PATCH 16/97] test(dev-infra): share gate log swap setup
---
scripts/run-gates.spec.ts | 70 +++++++++++++++++++++------------------
1 file changed, 38 insertions(+), 32 deletions(-)
diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts
index 46d41a5df8..13a37d0070 100644
--- a/scripts/run-gates.spec.ts
+++ b/scripts/run-gates.spec.ts
@@ -83,6 +83,30 @@ function temporaryRoot(prefix = 'dsh-gate-logs-'): string {
return root
}
+function invokeGateLogOperation(
+ operation: 'write' | 'prune' | 'clean',
+ subjectGate: Gate,
+ directory: string,
+ root: string,
+ beforeHelper: () => void,
+): Promise {
+ switch (operation) {
+ case 'write':
+ return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
+ directory,
+ repositoryRoot: root,
+ retention: 1,
+ unique: operation,
+ platform: 'linux',
+ beforeHelper,
+ })
+ case 'prune':
+ return pruneGateLogs(directory, 0, root, beforeHelper)
+ case 'clean':
+ return cleanGateFailureLogs(directory, root, beforeHelper)
+ }
+}
+
function withPnpmEntrypoint(action: () => T): T {
const previous = process.env.npm_execpath
process.env.npm_execpath = '/private/pnpm.cjs'
@@ -426,7 +450,6 @@ describe('gate failure logs', () => {
it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => {
const subjectGate = gate('subject')
- const subject = plan([subjectGate])
for (const operation of ['write', 'prune', 'clean'] as const) {
const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`)
@@ -448,21 +471,13 @@ describe('gate failure logs', () => {
symlinkSync(external, cache, 'dir')
}
- let invocation: Promise
- if (operation === 'write') {
- invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), {
- directory,
- repositoryRoot,
- retention: 1,
- unique: operation,
- platform: 'linux',
- beforeHelper: swapAncestor,
- })
- } else if (operation === 'prune') {
- invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor)
- } else {
- invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor)
- }
+ const invocation = invokeGateLogOperation(
+ operation,
+ subjectGate,
+ directory,
+ repositoryRoot,
+ swapAncestor,
+ )
await expect(invocation).rejects.toThrow('gate-log helper')
if (victim === undefined) {
@@ -477,7 +492,6 @@ describe('gate failure logs', () => {
it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => {
const subjectGate = gate('subject')
- const subject = plan([subjectGate])
for (const operation of ['write', 'prune', 'clean'] as const) {
const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`)
@@ -497,21 +511,13 @@ describe('gate failure logs', () => {
renameSync(externalCache, cache)
}
- let invocation: Promise
- if (operation === 'write') {
- invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), {
- directory,
- repositoryRoot,
- retention: 1,
- unique: operation,
- platform: 'linux',
- beforeHelper: swapAncestor,
- })
- } else if (operation === 'prune') {
- invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor)
- } else {
- invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor)
- }
+ const invocation = invokeGateLogOperation(
+ operation,
+ subjectGate,
+ directory,
+ repositoryRoot,
+ swapAncestor,
+ )
await expect(invocation).rejects.toThrow('gate-log helper')
if (victim === undefined) {
From 4ddec0ba2f3082588dc00f0647549da9ef06031d Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:38:24 +0800
Subject: [PATCH 17/97] refactor: command.execute degrades to pure admission;
composer notice channel retired
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The wire response now carries only the matched bit — CommandExecuteResult
is deleted from the api, schema, and client mirrors (pre-release, no shim);
outcomes ride the durably logged command/run/command/done pair broadcast on
the mux stream and render as flow nodes. ui-command's runDetached→noticeFor
outcome routing is retired: admitted commands surface nothing through the
composer, while admission misses (matched:false, syntax feedback) and
transport failures keep their immediate notice. The connection fixture
mirrors the host: an admitted command appends the lifecycle pair to the
session log instead of returning result text.
---
packages/client/connection/src/client/api.ts | 2 +-
.../client/connection/src/client/fixture.ts | 26 +++++++-------
.../client/connection/src/client/index.ts | 2 +-
packages/client/connection/tests/fake-api.ts | 4 +--
.../connection/tests/fixture-commands.spec.ts | 26 +++++++++++---
.../client/ui-command/src/client/service.ts | 34 +++++++++++-------
.../client/ui-command/tests/service.spec.ts | 36 +++++++++----------
packages/host/apiproxy/README.i18n.yaml | 4 +--
packages/host/apiproxy/README.md | 2 +-
packages/host/apiproxy/README.zh.md | 2 +-
packages/host/apiproxy/src/api-proxy.ts | 9 +++--
.../host/apiproxy/src/api/commands.schema.ts | 11 ++----
packages/host/apiproxy/src/api/commands.ts | 19 +++++-----
packages/host/apiproxy/src/api/index.ts | 2 +-
.../apiproxy/tests/api-proxy-commands.spec.ts | 9 ++++-
.../host/apiproxy/tests/fetch-carrier.spec.ts | 4 +--
.../host/apiproxy/tests/rpc-schemas.spec.ts | 8 ++---
17 files changed, 110 insertions(+), 90 deletions(-)
diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts
index edc5b2e25d..6bc07fd488 100644
--- a/packages/client/connection/src/client/api.ts
+++ b/packages/client/connection/src/client/api.ts
@@ -9,7 +9,7 @@ export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView,
- CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
+ CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index f53f7ac22e..c1cd17f741 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -808,25 +808,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
],
})
},
+ // Pure admission, mirroring the host: an admitted command logs the
+ // command/run + command/done lifecycle pair (mux-broadcast by append),
+ // and the response only reports resolution.
execute: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
+ const id = request.payload.sessionId
const line = request.payload.line.trim()
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
const name = match?.[1]
- if (name === 'compact' || name === 'echo') {
- return ok(request, {
- matched: true as const,
- result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture:已压缩(假动作)' },
- })
+ const outcomes: Record = {
+ compact: 'fixture:已压缩(假动作)',
+ echo: match?.[2] ?? '',
+ 'goal-fixture': `fixture:goal 已设置(${id})`,
}
- if (name === 'goal-fixture') {
- return ok(request, {
- matched: true as const,
- result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` },
- })
- }
- return ok(request, { matched: false as const })
+ const text = name === undefined ? undefined : outcomes[name]
+ if (name === undefined || text === undefined) return ok(request, { matched: false as const })
+ const commandId = `fx-cmd-${logOf(id).length}`
+ append(id, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } })
+ append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
+ return ok(request, { matched: true as const })
},
},
skills: {
diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts
index d4505eb659..3b1beb1f83 100644
--- a/packages/client/connection/src/client/index.ts
+++ b/packages/client/connection/src/client/index.ts
@@ -14,7 +14,7 @@ export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
- CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
+ CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts
index cfabe476e3..b5982e3729 100644
--- a/packages/client/connection/tests/fake-api.ts
+++ b/packages/client/connection/tests/fake-api.ts
@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
- CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
+ CommandDescriptor, HostFrame, IApiClient, MuxFrame,
RpcRequest, RpcResponse, SessionId, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -94,7 +94,7 @@ export class FakeApiClient implements IApiClient {
// wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ commands: [] }))
- onCommandExecute: (payload: unknown) => Promise>
+ onCommandExecute: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ skills: [] }))
diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts
index d3c62e736b..6840ec50b3 100644
--- a/packages/client/connection/tests/fixture-commands.spec.ts
+++ b/packages/client/connection/tests/fixture-commands.spec.ts
@@ -36,20 +36,36 @@ describe('createFixtureApi commands/skills', () => {
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
- it('executes a known command line and reports matched with a result', async () => {
+ it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
const api = createFixtureApi()
+ const frames: unknown[] = []
+ const abort = new AbortController()
+ const stream = api.events.mux(req({}), abort.signal)
+ const pump = (async () => {
+ for await (const frame of stream) {
+ frames.push(frame.payload)
+ if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
+ }
+ })()
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
if (!response.result.ok) throw new Error('execute failed')
- expect(response.result.value.matched).toBe(true)
- expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' })
+ expect(response.result.value).toEqual({ matched: true })
+ await pump
+ const events = frames
+ .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event')
+ .map(f => f.event)
+ expect(events).toMatchObject([
+ { type: 'command/run', data: { name: 'echo', line: '/echo hello world', source: { kind: 'user' } } },
+ { type: 'command/done', data: { kind: 'success', text: 'hello world' } },
+ ])
+ expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
})
- it('addresses execute to the session (result text carries the id)', async () => {
+ it('addresses execute to the session; an unknown session errs', async () => {
const api = createFixtureApi()
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
if (!hit.result.ok) throw new Error('execute failed')
expect(hit.result.value.matched).toBe(true)
- expect(hit.result.value.result?.text).toContain('fx-alpha')
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts
index df59ad2dcd..22f4a911b3 100644
--- a/packages/client/ui-command/src/client/service.ts
+++ b/packages/client/ui-command/src/client/service.ts
@@ -227,7 +227,15 @@ export class CommandService extends Service implements CommandServiceContract {
}
}
- /** The command.execute transaction, addressed to the session's agent. */
+ /**
+ * The command.execute transaction, addressed to the session's agent — pure
+ * admission semantics. An unmatched line reports an error outcome (the
+ * composer's immediate admission feedback); an admitted command reports
+ * plain success regardless of its handler outcome, because the host
+ * executor durably logged the lifecycle (`command/run`/`command/done`) and
+ * the outcome renders as a persistent flow node — the composer never
+ * echoes it. Transport failures throw.
+ */
private async execute(
session: ClientSessionContext,
line: string,
@@ -236,25 +244,25 @@ export class CommandService extends Service implements CommandServiceContract {
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
- const detached = result.value.result
- return detached === undefined
- ? { kind: 'success' }
- : { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) }
+ return { kind: 'success' }
}
/**
- * Fire-and-forget execute for the internal ('handled') paths. The detached
- * result surfaces as a notice routed to the triggering session's composer,
- * so a late result lands on its own session after a switch.
+ * Fire-and-forget execute for the internal ('handled') paths. Outcomes are
+ * NOT surfaced here: the host executor durably logs the command lifecycle
+ * (`command/run`/`command/done`), and the mux-broadcast events render as a
+ * persistent flow node on every tab. Only a transport/admission failure —
+ * which never entered a handler and therefore never logged — falls back to
+ * the composer notice as immediate feedback.
*/
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
void this.execute(session, line).then(
(outcome) => {
- if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`)
- else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text)
+ // matched:false maps to an error outcome with no logged lifecycle.
+ if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`)
},
(error: unknown) => {
- this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error))
+ this.noticeFor(session.sessionId, 'error', error instanceof Error ? error.message : String(error))
},
)
}
@@ -270,8 +278,8 @@ export class CommandService extends Service implements CommandServiceContract {
})
}
- /** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */
- private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
+ /** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */
+ private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void {
const actx = this.scopeFor(id)
if (actx === undefined) return
const conversation = actx.get('conversation')
diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts
index 0cf94e2f82..d3e6b5d34c 100644
--- a/packages/client/ui-command/tests/service.spec.ts
+++ b/packages/client/ui-command/tests/service.spec.ts
@@ -31,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
]
-type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } }
+type ExecuteValue = { matched: boolean }
interface BenchOptions {
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
@@ -361,16 +361,18 @@ describe('matchEnter (enter column)', () => {
})
describe('execute payload', () => {
- it('claim.submit addresses the session and maps the detached result', async () => {
+ it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
const { source, warm, executeCalls } = await bench({
- execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }),
+ execute: () => Promise.resolve({ matched: true }),
})
await warm(proj('s1'))
const outcome = source.matchSpace!(proj('s1'), '/goal')
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
const settled = await outcome.claim.submit('ship it', new Context())
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
- expect(settled).toEqual({ kind: 'success', text: 'goal set' })
+ // Pure admission: no outcome text ever rides the submit result — the
+ // durable command lifecycle events render the outcome in the flow.
+ expect(settled).toEqual({ kind: 'success' })
})
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
@@ -389,33 +391,29 @@ describe('execute payload', () => {
})
})
-describe('detached result notices', () => {
+describe('detached admission notices', () => {
const flush = () => new Promise(resolve => setTimeout(resolve, 0))
- it('success text → info; error result → error; rejection → error, all on the triggering session', async () => {
- let mode: 'info' | 'error' | 'reject' = 'info'
+ it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => {
+ let mode: 'admitted' | 'miss' | 'reject' = 'admitted'
const { source, mint, warm, notices } = await bench({
execute: () => {
if (mode === 'reject') return Promise.reject(new Error('network down'))
- return Promise.resolve({
- matched: true,
- result: mode === 'info'
- ? { kind: 'success' as const, text: 'compacted 12 messages' }
- : { kind: 'error' as const, text: 'plan mode refused' },
- })
+ return Promise.resolve({ matched: mode === 'admitted' })
},
})
mint('s1')
await warm(proj('s1'))
+ // Admitted: the durable lifecycle events own the outcome — no notice.
menuPick(source, 'plan', proj('s1'))
await flush()
- expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }])
+ expect(notices).toEqual([])
- notices.length = 0
- mode = 'error'
+ // Admission miss (matched:false): immediate composer feedback stays.
+ mode = 'miss'
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
await flush()
- expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'plan mode refused' }])
+ expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
notices.length = 0
mode = 'reject'
@@ -424,9 +422,9 @@ describe('detached result notices', () => {
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
})
- it('success without text stays silent; a torn-down scope drops the notice', async () => {
+ it('a torn-down scope drops the failure notice', async () => {
const { source, warm, notices } = await bench({
- execute: () => Promise.resolve({ matched: true, result: { kind: 'success' as const, text: 'orphan' } }),
+ execute: () => Promise.reject(new Error('orphan failure')),
})
await warm(proj('ghost')) // never minted: scopeFor misses
menuPick(source, 'plan', proj('ghost'))
diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml
index aee09384de..3e340567a8 100644
--- a/packages/host/apiproxy/README.i18n.yaml
+++ b/packages/host/apiproxy/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/host/apiproxy/README.md
-README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86
-README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c
+README.md: 0e8699e513452030bfa4ffc62737df928c161603
+README.zh.md: 8b19d0357389f616ec8a4120beb2cf8d8d7686d8
diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md
index 69e616193b..0e8699e513 100644
--- a/packages/host/apiproxy/README.md
+++ b/packages/host/apiproxy/README.md
@@ -20,7 +20,7 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
-The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
+The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports only whether the line resolved to a handler, while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md
index d79628ca3e..8b19d03573 100644
--- a/packages/host/apiproxy/README.zh.md
+++ b/packages/host/apiproxy/README.zh.md
@@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
-`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
+`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应只报告该行是否解析到处理器,结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)
diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index ae23d9fd47..ad0f8fc8e4 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -919,12 +919,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
try {
+ // Pure admission: the executor's durable command/run + command/done
+ // pair (broadcast on the mux stream) carries the outcome; the
+ // response only reports whether the line resolved to a handler.
const result = await commands.execute(found.agent, line, signal)
- if (result === undefined) return ok(request, { matched: false })
- return ok(request, {
- matched: true,
- result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
- })
+ return ok(request, { matched: result !== undefined })
} catch (error: unknown) {
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts
index d748d609c1..ba0c5a8e0e 100644
--- a/packages/host/apiproxy/src/api/commands.schema.ts
+++ b/packages/host/apiproxy/src/api/commands.schema.ts
@@ -7,7 +7,7 @@ import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
-import type { CommandDescriptor, CommandExecuteResult } from './commands.ts'
+import type { CommandDescriptor } from './commands.ts'
/** CommandDescriptor row of command.list. */
export const commandDescriptorSchema = z.object({
@@ -32,14 +32,7 @@ export const commandExecuteRequestSchema = z.object({
line: z.string(),
}) satisfies z.ZodType>>
-/** Detached command outcome (result slot of command.execute's value). */
-export const commandExecuteResultSchema = z.object({
- kind: z.union([z.literal('success'), z.literal('error')]),
- text: z.string().optional(),
-}) satisfies z.ZodType>
-
-/** command.execute response value (matched=false carries no result). */
+/** command.execute response value: pure admission — outcomes ride the logged lifecycle events, never this response. */
export const commandExecuteValueSchema = z.object({
matched: z.boolean(),
- result: commandExecuteResultSchema.optional(),
}) satisfies z.ZodType>>
diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts
index 7520d91804..08d25a4dec 100644
--- a/packages/host/apiproxy/src/api/commands.ts
+++ b/packages/host/apiproxy/src/api/commands.ts
@@ -22,12 +22,6 @@ export interface CommandDescriptor {
readonly input?: { readonly hint: string }
}
-/** Detached command outcome rendered directly by the requesting client. */
-export interface CommandExecuteResult {
- readonly kind: 'success' | 'error'
- readonly text?: string
-}
-
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
export interface CommandsApi {
/**
@@ -38,11 +32,14 @@ export interface CommandsApi {
/**
* Parses and executes one slash-command line against the addressed agent
- * without sending it to the model. matched=false when syntax or name does
- * not resolve (the client falls back to its default sink). The signal rides
- * beside the request, never on the wire: the fetch carrier's request signal
- * cancels the running handler.
+ * without sending it to the model — pure admission semantics. matched=false
+ * when syntax or name does not resolve (the client falls back to its
+ * default sink). The handler's outcome does NOT ride the response: the host
+ * executor durably logs the lifecycle (`command/run`/`command/done`), which
+ * broadcasts on the mux stream and renders as a persistent flow node. The
+ * signal rides beside the request, never on the wire: the fetch carrier's
+ * request signal cancels the running handler.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
- Promise>
+ Promise>
}
diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts
index 23b08a2ef0..976f80abbc 100644
--- a/packages/host/apiproxy/src/api/index.ts
+++ b/packages/host/apiproxy/src/api/index.ts
@@ -28,7 +28,7 @@ export interface ApiProxy {
export type { HistoryEntry, SessionProjectionsBlock, SessionsApi, SessionSummary } from './sessions.ts'
export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
-export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
+export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
index 480f821b95..f284549335 100644
--- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
@@ -115,8 +115,15 @@ describe('command.execute', () => {
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
- expect(value).toEqual({ matched: true, result: { kind: 'success', text: `goal:${agent.id}` } })
+ expect(value).toEqual({ matched: true })
expect(received).toBe(' ship it')
+ // Pure admission on the wire: the outcome rides the durably logged
+ // lifecycle pair instead of the response.
+ const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
+ expect(lifecycle).toMatchObject([
+ { type: 'command/run', data: { name: 'goal', line: '/goal ship it' } },
+ { type: 'command/done', data: { kind: 'success', text: `goal:${agent.id}` } },
+ ])
})
it('returns matched:false when syntax or name does not resolve', async () => {
diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
index f90fd72e8a..d4d146294b 100644
--- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts
+++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
@@ -91,7 +91,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
}
if (request.payload.line.startsWith('/plan')) {
- return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, result: { kind: 'success' as const, text: 'plan set' } } } }
+ return { rpcId: request.rpcId, result: { ok: true, value: { matched: true } } }
}
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
},
@@ -163,7 +163,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const list = await c.commands.list({ sessionId: 's' as never })
expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
- expect(hit.result).toEqual({ ok: true, value: { matched: true, result: { kind: 'success', text: 'plan set' } } })
+ expect(hit.result).toEqual({ ok: true, value: { matched: true } })
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
const skills = await c.skills.list({ sessionId: 's' as never })
diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
index 5002f8b857..669bef3e45 100644
--- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts
+++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
@@ -215,10 +215,10 @@ describe('commands domain schemas', () => {
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
- const matched = commandExecuteValueSchema.parse({ matched: true, result: { kind: 'success', text: 'done' } })
- expect(matched.result?.kind).toBe('success')
- expect(commandExecuteValueSchema.parse({ matched: true, result: { kind: 'error', text: 'bad' } }).result?.kind).toBe('error')
- expect(() => commandExecuteValueSchema.parse({ matched: true, result: { kind: 'other' } })).toThrow()
+ // Pure admission: the value carries only the matched bit (outcomes ride
+ // the logged lifecycle events, never this response).
+ expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
+ expect(() => commandExecuteValueSchema.parse({})).toThrow()
})
})
From 4fcfcf32d5ac160585fae2279a86e6e56792f180 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:45:39 +0800
Subject: [PATCH 18/97] test: replace tuple casts with structural lifecycle
assertions in command specs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two aggregate-typecheck errors the package-level tsc -b (rootDir=src) never
saw: the commands spec's two-tuple as-cast over the lifecycle slice
(TS2352, host aggregate) becomes a plain commandId projection, and the
fixture spec still read the deleted result member off the pure-admission
execute value (TS2339, client aggregate) — the matched bit is now asserted
as the whole response shape.
---
packages/client/connection/tests/fixture-commands.spec.ts | 4 ++--
packages/ui/commands/tests/commands.spec.ts | 5 +++--
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts
index 6840ec50b3..a71a371973 100644
--- a/packages/client/connection/tests/fixture-commands.spec.ts
+++ b/packages/client/connection/tests/fixture-commands.spec.ts
@@ -76,8 +76,8 @@ describe('createFixtureApi commands/skills', () => {
for (const line of ['/nope', 'plain text', '/']) {
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
if (!response.result.ok) throw new Error('execute failed')
- expect(response.result.value.matched).toBe(false)
- expect(response.result.value.result).toBeUndefined()
+ // Pure admission value: the matched bit is the whole response shape.
+ expect(response.result.value).toEqual({ matched: false })
}
})
diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts
index d030b0830b..941db73522 100644
--- a/packages/ui/commands/tests/commands.spec.ts
+++ b/packages/ui/commands/tests/commands.spec.ts
@@ -307,8 +307,9 @@ describe('CommandService', () => {
{ type: 'command/run', data: { name: 'deploy', line: '/deploy now', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
])
- const [run, done] = lifecycle as [{ data: { commandId: string } }, { data: { commandId: string } }]
- expect(run.data.commandId).toBe(done.data.commandId)
+ const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
+ expect(ids[0]).toBeTruthy()
+ expect(ids[0]).toBe(ids[1])
// Zero-step wrap: the pair stays turn-enclosed on an idle log.
expect(agent.session.events.map(event => event.type)).toEqual([
'turn/start', 'command/run', 'turn/end',
From f72f06e84a153015047c9ab3bb5ae64345a9c923 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 20:53:55 +0800
Subject: [PATCH 19/97] rfc: converge the projection contract on state-driven
units, host-side push, and the command channel
Rewrites the proposed session-projection note to the settled architecture:
ProjectionDefinition (init/apply/view/stateVersion) replaces the opaque
get(agent) provider; the host is the only computation site (eager drive,
watermark cache, session/projection push frame); the client reduces to a
generic seq-guarded value store with zero per-domain code; plan selection
routes through the standard command channel ({name, args} structured
command/run, both plan RPCs retired, pending becomes a pure replay
quantity); the persisted projection cache (sessionId/key/stateVersion/
observedSeq/state rows) is the later cold-read phase; reverse scans and
absorber declarations are rejected for now. Chinese counterpart updated
per-section, pairing re-recorded.
---
...ssion-projection-and-command-log.i18n.yaml | 4 +-
...7-27-session-projection-and-command-log.md | 93 ++++++++++++------
...7-session-projection-and-command-log.zh.md | 95 ++++++++++++-------
3 files changed, 127 insertions(+), 65 deletions(-)
diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml
index 3796963b1a..22e7a7b764 100644
--- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml
+++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.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/proposed/architecture/2026-07-27-session-projection-and-command-log.md
-2026-07-27-session-projection-and-command-log.md: 0378530c42b0a041c2dc4a248228c0a3fa6a757a
-2026-07-27-session-projection-and-command-log.zh.md: 6f5e6efb40e949b0bc04bc0e85061c084f52c91a
+2026-07-27-session-projection-and-command-log.md: a8495f958b209d1f515f111834cbcf0551393bc0
+2026-07-27-session-projection-and-command-log.zh.md: 89dd865b0562e94ef602970bf57a71b7ce53928d
diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
index 0378530c42..a8495f958b 100644
--- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
+++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
@@ -20,19 +20,28 @@ Four infrastructure pieces, then the domains become pure contributors.
### Whole-value event rule
-A state-carrying log event MUST carry the complete post-change state, never a delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). Under this rule the client-side fold degenerates to **last-wins**: a domain's state is the whole value carried by the highest-seq domain event seen. No client-side state machine (goal's revision/CAS/phase checks stay at the host write path), no history dependence, out-of-order immunity by seq comparison, and self-healing — a missed event is corrected by the next one.
+A state-carrying log event MUST carry the complete post-change state, never a bare delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). The rule keeps every domain's transition trivially cheap (the framework drives it per event), keeps values self-describing on the wire, and lets any consumer treat the latest pushed value as final — out-of-order immunity by seq comparison, self-healing because a missed update is corrected by the next one.
### Host projection registry (`dsh-session-projection`, new package)
A light interface package: the merge-extensible type map, the registry service, zod at the boundary. Capability-seam three-way split: domain host plugins contribute, carriers consume, neither knows the other.
+What a domain registers is a **state-driven computation unit** — three pure functions plus declarations — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the mathematics. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract.
+
```ts
export interface SessionProjectionMap {} // the single type table for the whole chain
-export interface ProjectionProvider {
+export interface ProjectionDefinition {
key: K
schema: ZodType // validates the payload before it leaves the host
- get(agent: Agent): SessionProjectionMap[K] // MUST be synchronous; whole current value
+ /** State for the empty log. */
+ init(): S
+ /** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
+ apply(state: S, event: SessionEvent): S
+ /** State → wire payload (the read-side projection). */
+ view(state: S): SessionProjectionMap[K]
+ /** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
+ stateVersion: number
}
declare module 'cordis' {
@@ -40,8 +49,10 @@ declare module 'cordis' {
}
```
-- Values are wire JSON payloads; the same map typed end to end (host provider, wire block, client cell, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
-- `get` runs against the host's full in-memory log (`agent.session.events`) — pagination exists only in the history slice returned to the client, never in the provider's view, so "the window lacks the event" cannot lose state on the host. A last-wins domain may backscan (bounded: first hit from the tail terminates; the events live in memory); a domain with an expensive fold keeps an incremental cache keyed by observed seq (goal's `GoalCache` is the template). Either way the provider returns the current whole value synchronously.
+- Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
+- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
+- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
+- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
- The package owns `./invariant` (every served key has a live registration).
@@ -57,23 +68,30 @@ The api-proxy history handler, after slicing the tail page, reads `session.seq`,
No new RPC method. The timing coincidence is exact: every moment the client needs a fresh baseline (open, reconnect resync, gap repair) already pulls the tail page, and the only path that never needs one (loadOlder) is the only path that passes `beforeSeq`. The client therefore has **no** independent "refetch the baseline" decision at all. Window content is never a signal: "no domain event in the window" is unanswerable there by construction, and only the baseline answers it.
-Retired by this block: `session.planMode` (read side; `setPlanMode` stays), `goals.get` (read side; the six mutation RPCs stay, their responses no longer feed state — the mux event arrives anyway), the `todos` rider field, and `backscanTodos` in api-proxy (moves into the todo domain's provider, in `tool-todo`).
+Retired by this block: `session.planMode` and `setPlanMode` (both sides — plan selection goes through the standard command channel, see the plan section), `goals.get` (read side; the six mutation RPCs stay, their responses no longer feed state — the mux event arrives anyway), the `todos` rider field, and `backscanTodos` in api-proxy (moves into the todo domain's unit, in `tool-todo`).
-### Client: session-scope event dispatch and projection cells
+### Push frame and the client value store (domains write zero client code)
-The client runtime `Session` object gains a dispatch seam at its two event entrances — `appendLive(event)` (live signal) and `installWindow(…)` (window-replace signal, plus baseline reset when the response carries a projections block). Live and window-replace are distinguishable signals: that distinction is what #527 hand-rolled to avoid refetch storms and #587 hand-rolled to re-scan replacement windows. The core class returns to pure transcript concerns; the domain switches leave `applyEventSideEffects`.
-
-Domain client plugins register **projection cells** at scope materialization (the `InputHub.shellFor` pattern; teardown rides the scope fiber):
+Because the host is the only computation site, finished values reach clients over one new mux frame:
```ts
-export interface ProjectionCellSpec {
- key: K
- schema: ZodType // validates the baseline at the wire boundary
- fromEvent(event: SessionEvent): SessionProjectionMap[K] | undefined // whole value, or not-my-event
-}
+// MuxFrame union + schema branch:
+{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
```
-Framework semantics, implemented once for all cells: a `lastAppliedSeq` watermark initialized from the baseline's `asOfSeq`; one application rule — `event.seq > watermark` and `fromEvent` hit ⇒ take the whole value, raise the watermark, `markDirty` (Notifier batching); live and window-replace events pass the same filter, so replayed old pages are dropped by seq and can never roll state back; a baseline reset re-seeds value and watermark, and a key absent from the block marks the capability absent. All the per-domain fences (#587's three layers, #527's write revision) dissolve into this one seq rule. Plan's pending intent stays out of the log (turn-enclosure) but inside the projection value — the host's `planMode.get()` already returns exactly that shape; pending is not propagated to other tabs (accepted: it is the issuing tab's local "awaiting boundary" fact; other tabs see the commit event).
+The framework emits it whenever a unit's state reference changes (`Object.is` gate above); `seq` is the unit's watermark at emission. This is live push state, never logged — the same posture as the tool-view `view` slot: replay recomputes on the host.
+
+The client object layer keeps one **generic value store** per session: `key → { value, seq }`, seeded by the tail page's projections block and updated by the frame, under the single rule **higher seq wins**. Replayed baselines cannot roll a newer frame back; a lost frame costs staleness until the next frame or baseline, never wrongness. No `fromEvent`, no per-domain cell registration, no client-side domain folding — a domain ships projection support with **zero client code** (the `SessionProjectionMap` merge serves both sides through the `/types` outlet). The bespoke `session/title` frame and the manager's title-snapshot map retire into this generic pair. All the per-domain fences (#587's three layers, #527's write revision) dissolve into the one seq rule.
+
+### Plan through the standard command channel (worked example)
+
+Plan mode demonstrates the full pattern — trigger path, run plane, and replay plane, cleanly separated:
+
+- **Trigger path**: the web plan toggle sends `/plan` / `/plan off` through `command.execute` like any other command; the dedicated `setPlanMode`/`planMode` RPCs are retired. The user's *request* is durably recorded as that command's `command/run { name: 'plan', args: 'off' | '' }` — structured fields, no line parsing.
+- **Run plane** (unchanged): the plan-mode service keeps its in-memory pending intent and flushes `plan/mode` at the next turn boundary. On cold start the service rebuilds its intent queue from the replay plane ("empty run state means the replay state").
+- **Replay plane**: plan's projection unit folds **two** event types — its own `command/run` records set `wanted`; `plan/mode` sets `active` and clears `wanted`; `view` derives `{ active, pending: wanted !== null && wanted !== active }`. Pending is thereby a pure replay quantity: host restarts recover it, other tabs fold the same events (cross-tab pending for free), and a cold read answering `{ active: false, pending: true }` is accurate ("an unfulfilled selection awaits resume").
+
+A domain's input event set is its own choice — that is the general rule this example instantiates. Whether "the user asked for X" appears in a projection (plan folds its command records) or only in the flow (the command node renders anyway) is per-domain semantics, never a framework concern.
### React: `useProjection`, the fifth framework hook seat
@@ -88,7 +106,7 @@ type UseProjection = {
}
```
-`undefined` uniformly means capability absent (host plugin unmounted, client plugin unmounted, or baseline not yet landed). Cells expose bare `{subscribe, getSnapshot}`; `bindSnapshotSelector` with per-cell caching does the rest — reference stability holds because whole values are frozen event data, identical between events. Write paths are unchanged: mutation callbacks stay in the inject share (callbacks out of inject, live state out of `useProjection`).
+`undefined` uniformly means capability absent (host plugin unmounted, or no baseline/frame has carried the key). The value store exposes bare per-key `{subscribe, getSnapshot}` faces; `bindSnapshotSelector` with per-key caching does the rest — reference stability holds because a key's value reference changes only when a frame or baseline lands. Write paths are unchanged: mutation callbacks stay in the inject share (callbacks out of inject, live state out of `useProjection`).
The one existing violation of "no hooks through inject" — `DetailsInjected.useSelection` — is folded in with this change: selection is viewing state living in the chat store, so the details registration declares the shared store handle and the component reads `props.useStore(s => s.selection)`; `useSelection` leaves the inject contract.
@@ -97,30 +115,39 @@ The one existing violation of "no hooks through inject" — `DetailsInjected.use
Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing:
```ts
-'command/run': { commandId: string; name: string; line: string; source: CommandSource }
+'command/run': { commandId: string; name: string; args: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
-Both merged into `OutOfBandSessionEventMap`. The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
+Both merged into `OutOfBandSessionEventMap`. The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement; on an idle log the pair rides a zero-step turn wrap (`TurnTriggerMap 'command'`) so turn enclosure holds without a model request. The payload is structured — `name` and `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
-Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to pure admission (matched or not, syntax errors back to the composer immediately); the one-shot notice channel (`runDetached` → `noticeFor`) is retired.
+Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission — `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached` → `noticeFor`) is retired.
-The client flow builder gains one generic command node (run/done paired by `commandId`; cross-window cuts soft-fall like tool pairs). Rendering goes through a new keyed slot `'conversation.chat.commandview'`, key = command name, **fallback = a generic command card** (zero registration required — the former notice text now renders durably in the flow). A domain upgrades by registering one row component, drawing on `command/run.line` and its own cell state — the same shape as tool rows after the toolview dissolution.
+The client flow builder gains one generic command node (run/done paired by `commandId`; cross-window cuts soft-fall like tool pairs). Rendering goes through a new keyed slot `'conversation.chat.commandview'`, key = command name, **fallback = a generic command card** (zero registration required — the former notice text now renders durably in the flow). A domain upgrades by registering one row component, drawing on `command/run`'s structured fields and its own projection value (`useProjection`) — the same shape as tool rows after the toolview dissolution.
## Delivery plan
Infrastructure first; the three in-flight PRs are left untouched and re-target after the base lands (their migration mapping is the guide):
-1. **Host base**: `dsh-session-projection` + api-proxy projections block. Mergeable with zero domains registered (block simply absent).
-2. **Client base**: dispatch seam + cell framework + `useProjection` seat + the `useSelection` fold-in. Parallel with 1 (fixtures feed synthetic baselines).
-3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement. Parallel with 1.
-4. **Domain re-targets** (after 1+2): todo first (smallest: provider in `tool-todo`, cell from `todo/write`, drop the rider field), then plan (drop the unary and the fences), then goal (drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
+1. **Host base**: `dsh-session-projection` (unit contract, eager drive, watermark cache) + api-proxy projections block + the `session/projection` push frame. Mergeable with zero domains registered (block and frames simply absent).
+2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
+3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
+4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
+5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
## Alternatives considered
**A dedicated `session.projections` RPC** — rejected: baseline-refresh moments coincide exactly with tail-page pulls, so a separate unary buys a second round-trip, a second seq to reconcile, and a client-side "when to refetch" decision that the rider design deletes outright.
-**Naming the seam `registerFold`** — rejected: `get` does not promise a fold (goal reads a cache, plan overlays un-logged pending intent from service memory); `fold*` in this repo names pure `(events) => state` functions and the registry would dilute that. Projection is the event-sourcing term for exactly this read-model role, and both #587's note title and #497's comments already use it.
+**An opaque `get(agent)` provider contract** — rejected after being the first draft: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init, apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit.
+
+**A live-only overlay hook (`live?(agent, base)`) for plan's pending intent** — rejected: it existed solely because the user's plan *selection* was not in the log. Routing the selection through the standard command channel puts `command/run` on the account, pending becomes a pure replay quantity, and the projection contract stays exactly three pure functions.
+
+**Naming the seam `registerFold`** — superseded by the unit contract: the registered object now genuinely is a fold, but `fold*` in this repo names pure `(events) => state` helper functions while this seam registers a keyed, schema'd, versioned unit. Projection remains the event-sourcing term for the read-model role, and both #587's note title and #497's comments already use it.
+
+**Client-side folding (per-domain projection cells with a `fromEvent`)** — rejected after being the second draft: once plan's unit folds two event types, a client cell must duplicate the host's transition logic in the browser — the same fold written twice, evolving separately. Pushing finished values (the title-frame precedent, generalized) keeps one computation site and reduces the client to a generic seq-guarded value store; domains write zero client code.
+
+**Bounded reverse scan over the log tail (absorber declarations)** — rejected for now: nothing supports it today, it only serves domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row + forward tail replay — the same recipe as the client's baseline + catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve.
**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear.
@@ -130,22 +157,26 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
**Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots).
-**Propagating plan's pending intent across tabs** — deferred, not designed in: pending is deliberately un-logged (turn enclosure), a live non-logged control frame (the `session/queued` precedent) can add it later without touching this model.
+**A dedicated `plan/select` selection event (structured domain event instead of folding command records)** — rejected in favor of the command channel: `command/run`'s structured `{name, args}` already records the selection, the `/plan` grammar and its fold live in the same plugin (domain-internal coupling, not cross-domain), and one less event type. The handler must call `set()` before any failable path so the logged request and the run plane cannot diverge — a domain-internal ordering constraint, documented at the handler.
+
+**Keeping `setPlanMode` as a dedicated RPC** — rejected: plan selection is a user command like any other; the command channel gives it durable recording, flow rendering, multi-tab visibility, and admission semantics without a bespoke wire method. Web UI affordances (a toggle) compose the command line internally.
**Making mutation RPC responses feed cell state** — rejected: the committed mux event arrives immediately and carries the same whole value with a seq; responses feeding state is what required #527's write-revision fence.
## Acceptance criteria
-- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host `register`, one client cell registration, and inject callbacks — no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files beyond its own `SessionProjectionMap` merge.
+- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host unit `register`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files.
- The history tail page carries `projections` with `asOfSeq` equal to the window tail seq; loadOlder pages never carry it; a deployment without the registry serves histories without the block and clients treat every key as absent.
-- Replayed window events cannot regress cell state (watermark test); a baseline landing after a newer mux commit cannot overwrite it (seq rule test).
+- A stale baseline cannot overwrite a newer `session/projection` frame, and a replayed frame cannot regress the value store (higher-seq-wins tests on both paths).
- A slash command executed on one tab renders a durable node in the flow on refresh, on a second tab, and after resume; unregistered commands render the generic card; the composer notice path for command outcomes is gone.
- `useProjection` reaches components through the standard props kit; no hook crosses an inject contract (including `useSelection`).
+- Session titles ride the generic pair (baseline block + projection frame); the bespoke `session/title` frame and the client title-snapshot map are gone.
## Risks
-- **Whole-value rule is load-bearing**: a future domain logging deltas breaks last-wins silently. Mitigation: the rule is stated here and in the projection package README; cell `fromEvent` signatures make delta shapes unrepresentable without deliberate effort.
-- **Synchronous `get` discipline**: a provider that awaits would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest.
+- **Whole-value rule is load-bearing**: a future domain logging bare deltas cannot serve consumers from its latest event and complicates its own unit. Mitigation: the rule is stated here and in the projection package README; the unit contract makes the full state explicit at every transition.
+- **Synchronous unit discipline**: `init`/`apply`/`view` that await would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest.
+- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Units are cheap per-event by construction (whole-value rule), non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change.
- **Projection payload growth**: every tail page carries every registered key. Payloads are whole values of UI-scale state (a todo list, a goal snapshot); if a future domain's value is large, per-key opt-out or lazy keys can be added to the request without changing the model.
- **Command log volume**: two log-only events per slash command; bounded by human command frequency, negligible against chunk volume.
- **Re-target churn**: three open PRs rebase onto a moved foundation. Accepted cost of infrastructure-first; the migration mapping section in the design ledger names each PR's keep/drop list.
diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
index 6f5e6efb40..89dd865b05 100644
--- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
+++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
@@ -10,7 +10,7 @@ Status: proposed
- **客户端核心类吸收每一个领域。** 三者都往客户端运行时的 `Session` 类里添加私有字段、拉取编排和事件 switch 分支,并经 `ConversationSnapshot` 投出各自的值。仅 plan 一家就加了七个私有字段和三层栅栏(请求版本、事件版本、最新活值缓存);goal 加了写 revision 栅栏外加一个合并式重取循环;todo 加了一个投影(projection)字段和一条事件 case 分支。再来第四个领域,就要第四次改动核心类。
- **三条基线通道。** todo 搭在历史尾页的 `todos` 字段上——由 **api-proxy 内部**的 `backscanTodos` 计算,业务折叠(fold)逻辑寄居在载体里;plan 加了一个专用的 `session.planMode` 一元 RPC;goal 加了 `goals.get`。同一个问题,三种协议格式(wire format)。
-- **命令结果不可恢复。** `/goal`、`/plan` 以及其余所有斜杠命令都只在 `command.execute` RPC 响应里返回结果,以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下:刷新、另开标签页、恢复(resume)或 fork 都会丢掉「该命令曾经运行过」的记录。领域*状态*变更是持久的(goal 提交 `goal/change` 元数据,plan 提交 `plan/mode`),但命令调用本身及其结论不是。
+- **命令结果不可恢复。** `/goal`、`/plan` 以及其余所有斜杠命令都只在 `command.execute` RPC 响应里返回结果,以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下:刷新、另开标签页、恢复或 fork 都会丢掉「该命令曾经运行过」的记录。领域*状态*变更是持久的(goal 提交 `goal/change` 元数据,plan 提交 `plan/mode`),但命令调用本身及其结论不是。
底层缺口是架构性的:客户端没有一个 seam 让插件在会话 scope 内观察会话事件并维护自己的派生状态;host 侧也没有统一的方式把日志派生状态的当前值交给客户端——而该状态的历史可能已被分页挤出客户端窗口之外。
@@ -20,19 +20,28 @@ Status: proposed
### 全量值事件规则
-携带状态的日志事件必须携带变更后的完整状态,绝不携带增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。在该规则下,客户端侧的折叠退化为 **last-wins**:一个领域的状态,就是已见 seq 最高的该领域事件所携带的全量值。无需客户端状态机(goal 的 revision/CAS/阶段检查留在 host 侧写路径),不依赖历史,靠 seq 比较获得乱序免疫,而且自愈——漏掉的事件会被下一个事件纠正。
+携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。该规则让每个领域的状态转移始终足够廉价(框架逐事件驱动它),让值在协议层自描述,并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫,且自愈:漏掉的更新会被下一次更新纠正。
### host 侧投影注册表(`dsh-session-projection`,新包)
一个轻量的接口包(package):merge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 三方拆分:领域 host 插件负责贡献,载体负责消费,两侧互不相识。
+领域注册的是一个**状态驱动计算单元(state-driven computation unit)**——三个纯函数外加若干声明——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责数学本身。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本契约中没有任何特殊地位。
+
```ts
export interface SessionProjectionMap {} // the single type table for the whole chain
-export interface ProjectionProvider {
+export interface ProjectionDefinition {
key: K
schema: ZodType // validates the payload before it leaves the host
- get(agent: Agent): SessionProjectionMap[K] // MUST be synchronous; whole current value
+ /** State for the empty log. */
+ init(): S
+ /** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
+ apply(state: S, event: SessionEvent): S
+ /** State → wire payload (the read-side projection). */
+ view(state: S): SessionProjectionMap[K]
+ /** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
+ stateVersion: number
}
declare module 'cordis' {
@@ -40,8 +49,10 @@ declare module 'cordis' {
}
```
-- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 提供方、协议块、客户端 cell、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
-- `get` 面向 host 的全量内存日志(`agent.session.events`)运行——分页只存在于返回给客户端的历史切片里,绝不出现在提供方的视野中,所以「窗口里缺这个事件」在 host 侧不可能丢状态。last-wins 领域可以回扫(有界:从尾部起首个命中即终止;事件本就在内存里);折叠开销大的领域维护一份以已见 seq 为键的增量缓存(goal 的 `GoalCache` 即范本)。无论哪种方式,提供方都同步返回当前全量值。
+- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
+- **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
+- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
+- 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
- 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
@@ -57,23 +68,30 @@ api-proxy 的历史处理器切出尾页后读取 `session.seq`,然后同步
不新增 RPC 方法。时机上的重合是精确的:客户端每一个需要新基线的时刻(打开、重连重同步、缺口修补)本来就要拉尾页,而唯一永远不需要基线的路径(loadOlder)恰好是唯一传 `beforeSeq` 的路径。因此客户端**完全没有**独立的「重取基线」决策。窗口内容从不充当信号:「窗口里没有该领域的事件」这个问题在窗口内从构造上就无法回答,只有基线能回答它。
-随此块下线的旧通道:`session.planMode`(读侧;`setPlanMode` 保留)、`goals.get`(读侧;六个变更 RPC 保留,但其响应不再喂状态——mux 事件反正会到)、`todos` 搭载字段,以及 api-proxy 里的 `backscanTodos`(移入 todo 领域的提供方,落在 `tool-todo`)。
+随此块下线的旧通道:`session.planMode` 与 `setPlanMode`(读写两侧——plan 选择改走标准命令通道,见 plan 一节)、`goals.get`(读侧;六个变更 RPC 保留,但其响应不再喂状态——mux 事件反正会到)、`todos` 搭载字段,以及 api-proxy 里的 `backscanTodos`(移入 todo 领域的单元,落在 `tool-todo`)。
-### 客户端:会话 scope 的事件分发与投影 cell
+### 推送帧与客户端值仓(领域零客户端代码)
-客户端运行时的 `Session` 对象在它的两个事件入口——`appendLive(event)`(实时信号)与 `installWindow(…)`(窗口替换信号,响应携带 projections 块时附带基线重置)——获得一个分发 seam。实时与窗口替换是可区分的两种信号:#527 为避免重取风暴手工造出的、#587 为重扫替换窗口手工造出的,正是这个区分。核心类回归纯 transcript(文本记录)关切;各领域的 switch 分支撤出 `applyEventSideEffects`。
-
-领域客户端插件在 scope 物化时注册**投影 cell**(即 `InputHub.shellFor` 模式;销毁随 scope fiber 走):
+既然 host 是唯一计算地点,成品值经一个新的 mux 帧送达客户端:
```ts
-export interface ProjectionCellSpec {
- key: K
- schema: ZodType // validates the baseline at the wire boundary
- fromEvent(event: SessionEvent): SessionProjectionMap[K] | undefined // whole value, or not-my-event
-}
+// MuxFrame union + schema branch:
+{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
```
-框架语义对所有 cell 只实现一次:一条从基线 `asOfSeq` 初始化的 `lastAppliedSeq` 水位线(watermark);唯一一条应用规则——`event.seq > watermark` 且 `fromEvent` 命中 ⇒ 取全量值、抬高水位线、`markDirty`(Notifier 批处理);实时事件与窗口替换事件过同一道过滤,所以重放的旧页按 seq 被丢弃,永远不可能把状态往回滚;基线重置会重设值与水位线,块中缺席的 key 则把对应能力标记为缺失。所有按领域自造的栅栏(#587 的三层、#527 的写 revision)都消融进这一条 seq 规则。plan 的待定意图不入日志(turn-enclosure)但在投影值之内——host 的 `planMode.get()` 返回的恰是这个形状;待定态不向其他标签页传播(已接受:它是发起标签页本地的「等待边界」事实;其他标签页看到的是提交事件)。
+只要某单元的状态引用发生变化(上文的 `Object.is` 闸门),框架就发出该帧;`seq` 是发出时该单元的水位线。这是实时推送状态,绝不入日志——与 tool-view 的 `view` slot 同一姿态:回放时在 host 重新计算。
+
+客户端对象层为每个会话维护一个**通用值仓(value store)**:`key → { value, seq }`,由尾页的 projections 块播种、由该帧更新,唯一规则是 **seq 高者胜**。重放的基线无法把更新的帧往回滚;丢失一个帧的代价只是陈旧——到下一个帧或基线为止——绝不会出错。没有 `fromEvent`,没有按领域的 cell 注册,没有客户端侧领域折叠——领域交付投影支持只需**零客户端代码**(`SessionProjectionMap` merge 经 `/types` 出口同时服务两侧)。专设的 `session/title` 帧与 manager 的标题快照表都收编进这对通用机制。所有按领域自造的栅栏(#587 的三层、#527 的写 revision)都消融进这一条 seq 规则。
+
+### plan 走标准命令通道(完整示例)
+
+plan mode 完整演示了这套模式——触发路径、运行面、回放面,三者干净分离:
+
+- **触发路径**:web 的 plan 开关像任何其他命令一样经 `command.execute` 发送 `/plan` / `/plan off`;专设的 `setPlanMode`/`planMode` RPC 下线。用户的*请求*被持久记录为该命令的 `command/run { name: 'plan', args: 'off' | '' }`——结构化字段,无需解析行文本。
+- **运行面**(不变):plan-mode 服务在内存里保持待定意图,并在下一个轮次边界落下 `plan/mode`。冷启动时服务从回放面重建其意图队列(「运行态为空即以回放态为准」)。
+- **回放面**:plan 的投影单元折叠**两**种事件——它自己的 `command/run` 记录设置 `wanted`;`plan/mode` 设置 `active` 并清除 `wanted`;`view` 推导出 `{ active, pending: wanted !== null && wanted !== active }`。待定态由此成为纯回放量:host 重启能恢复它,其他标签页折叠同样的事件(跨标签页待定态随之自动获得),冷读回答 `{ active: false, pending: true }` 也是准确的(「一个未兑现的选择正等待恢复」)。
+
+领域的输入事件集由领域自己选择——本示例落实的正是这条一般规则。「用户请求过 X」是出现在投影里(plan 折叠自己的命令记录),还是只出现在 flow 里(命令节点反正会渲染),属于各领域自己的语义,永远不是框架的关切。
### React:`useProjection`,第五个框架钩子席位
@@ -88,7 +106,7 @@ type UseProjection = {
}
```
-`undefined` 统一表示能力缺失(host 插件未挂载、客户端插件未挂载,或基线尚未到达)。cell 只暴露裸的 `{subscribe, getSnapshot}`;其余交给带逐 cell 缓存的 `bindSnapshotSelector`——引用稳定性成立,因为全量值是冻结的事件数据,两次事件之间恒等不变。写路径不变:变更回调留在 inject 共享面(回调出自 inject,活状态出自 `useProjection`)。
+`undefined` 统一表示能力缺失(host 插件未挂载,或尚无任何基线/帧携带过该 key)。值仓只暴露按 key 的裸 `{subscribe, getSnapshot}` 面;其余交给带逐 key 缓存的 `bindSnapshotSelector`——引用稳定性成立,因为一个 key 的值引用只在帧或基线落地时才变化。写路径不变:变更回调留在 inject 共享面(回调出自 inject,活状态出自 `useProjection`)。
「钩子不得穿过 inject」的唯一既有违例——`DetailsInjected.useSelection`——随本变更一并收编:选中态是住在聊天 store 里的查看状态,因此 details 注册声明共享 store 句柄,组件改读 `props.useStore(s => s.selection)`;`useSelection` 退出 inject 契约。
@@ -97,30 +115,39 @@ type UseProjection = {
两个仅日志(非 surface、模型不可见)事件,镜像 `tool/call`/`tool/result` 的配对:
```ts
-'command/run': { commandId: string; name: string; line: string; source: CommandSource }
+'command/run': { commandId: string; name: string; args: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
-两者都合并进 `OutOfBandSessionEventMap`。host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
+两者都合并进 `OutOfBandSessionEventMap`。host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`;日志空闲时这对事件搭乘一个零步骤轮次包裹(`TurnTriggerMap 'command'`),使轮次封闭(turn enclosure)在没有模型请求的情况下依然成立。载荷是结构化的——`name` 与 `args` 就是解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
-由于已提交事件会在 mux 流上广播,刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为纯准入判定(是否匹配命中、语法错误立即打回 composer);一次性通知通道(`runDetached` → `noticeFor`)就此下线。
+由于已提交事件会在 mux 流上广播,刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id,发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached` → `noticeFor`)就此下线。
-客户端 flow 构建器新增一个通用命令节点(run/done 按 `commandId` 配对;跨窗口截断时与工具配对同样软降级)。渲染走一个新的 keyed slot `'conversation.chat.commandview'`,key = 命令名,**兜底 = 通用命令卡片**(零注册即可用——从前的通知文本现在持久地渲染在 flow 里)。领域要升级展示,只需注册一个行组件,取材于 `command/run.line` 与自己的 cell 状态——与 toolview 解散之后的工具行同一形状。
+客户端 flow 构建器新增一个通用命令节点(run/done 按 `commandId` 配对;跨窗口截断时与工具配对同样软降级)。渲染走一个新的 keyed slot `'conversation.chat.commandview'`,key = 命令名,**兜底 = 通用命令卡片**(零注册即可用——从前的通知文本现在持久地渲染在 flow 里)。领域要升级展示,只需注册一个行组件,取材于 `command/run` 的结构化字段与自己的投影值(`useProjection`)——与 toolview 解散之后的工具行同一形状。
## Delivery plan
基础设施先行;三个在途 PR(Pull Request)原样不动,待基座落地后重新对接(它们的迁移映射即指南):
-1. **host 基座**:`dsh-session-projection` + api-proxy 的 projections 块。零领域注册也可合入(此时块直接缺席)。
-2. **客户端基座**:分发 seam + cell 框架 + `useProjection` 席位 + `useSelection` 收编。与 1 并行(fixture(测试前置数据)喂合成基线)。
-3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线。与 1 并行。
-4. **领域重新对接**(在 1+2 之后):先 todo(最小:提供方进 `tool-todo`,cell 取自 `todo/write`,删掉搭载字段),再 plan(删掉一元 RPC 和各道栅栏),最后 goal(删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。
+1. **host 基座**:`dsh-session-projection`(单元契约、正向驱动、水位线缓存)+ api-proxy 的 projections 块 + `session/projection` 推送帧。零领域注册也可合入(此时块与帧直接缺席)。
+2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。
+3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
+4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。
+5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
## Alternatives considered
**专设一个 `session.projections` RPC**——不予采纳:基线刷新时刻与尾页拉取精确重合,单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq,以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。
-**把 seam 命名为 `registerFold`**——不予采纳:`get` 并不承诺折叠(goal 读缓存,plan 从服务内存叠加未入日志的待定意图);本仓库里 `fold*` 专指纯 `(events) => state` 函数,注册表会稀释这一命名。projection(投影)正是事件溯源中指称这种读模型角色的术语,#587 的 Note 标题与 #497 的评论也都已在使用它。
+**不透明的 `get(agent)` 提供方契约**——曾是第一稿,后被否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init, apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连。
+
+**为 plan 待定意图专设的仅实时叠加钩子(`live?(agent, base)`)**——不予采纳:它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后,`command/run` 上了账,待定态成为纯回放量,投影契约保持恰好三个纯函数。
+
+**把 seam 命名为 `registerFold`**——已被单元契约取代:注册对象如今确实是一个折叠,但本仓库里 `fold*` 专指纯 `(events) => state` 辅助函数,而该 seam 注册的是带 key、带 schema、带版本的单元。投影仍是事件溯源中指称读模型角色的术语,#587 的 Note 标题与 #497 的评论也都已在使用它。
+
+**客户端侧折叠(带 `fromEvent` 的按领域投影 cell)**——曾是第二稿,后被否决:一旦 plan 的单元要折叠两种事件,客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值(标题帧先例的泛化)保住唯一计算地点,并把客户端简化为一个由 seq 把守的通用值仓;领域零客户端代码。
+
+**对日志尾部的有界反向扫描(absorber 声明)**——暂不采纳:今天没有任何东西需要它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。
**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。
@@ -130,22 +157,26 @@ type UseProjection = {
**用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot)。
-**把 plan 的待定意图跨标签页传播**——推迟,不纳入本设计:待定态是刻意不入日志的(turn enclosure),一种实时的非日志控制帧(先例 `session/queued`)日后可以在完全不动本模型的前提下补上它。
+**专设 `plan/select` 选择事件(用结构化领域事件替代折叠命令记录)**——不予采纳,改用命令通道:`command/run` 的结构化 `{name, args}` 已经记录了选择,`/plan` 的语法与其折叠逻辑同住一个插件(领域内耦合,非跨领域),还少一种事件类型。处理器必须在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉——这是领域内部的顺序约束,文档写在处理器处。
+
+**保留 `setPlanMode` 专用 RPC**——不予采纳:plan 选择就是一条普通的用户命令;命令通道给它持久记录、flow 渲染、多标签页可见性与准入语义,不需要专设协议方法。Web UI 的交互组件(一个开关)在内部拼出命令行即可。
**让变更 RPC 的响应喂 cell 状态**——不予采纳:已提交的 mux 事件即刻到达,携带同一个全量值外加 seq;「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。
## Acceptance criteria
-- 领域插件把按会话的日志派生状态送达 React,只需写:全量值事件声明、一次 host 侧 `register`、一次客户端 cell 注册、以及 inject 回调——除自己那份 `SessionProjectionMap` merge 之外,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
+- 领域插件把按会话的日志派生状态送达 React,只需写:全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
- 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seq;loadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。
-- 重放的窗口事件不能让 cell 状态倒退(水位线测试);在更新的 mux 提交之后才落地的基线不能覆盖该提交(seq 规则测试)。
+- 陈旧的基线不能覆盖更新的 `session/projection` 帧,重放的帧也不能让值仓倒退(两条路径都做 seq 高者胜测试)。
- 在一个标签页执行的斜杠命令,刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点;未注册的命令渲染通用卡片;命令结果的 composer 通知路径彻底移除。
- `useProjection` 经标准 props 套件抵达组件;没有任何钩子穿过 inject 契约(包括 `useSelection`)。
+- 会话标题搭乘这对通用机制(基线块 + 投影帧);专设的 `session/title` 帧与客户端标题快照表彻底移除。
## Risks
-- **全量值规则是承重结构**:未来某个领域若记增量事件,会无声地破坏 last-wins。缓解:该规则写明在本 Note 与投影包的 README 里;cell 的 `fromEvent` 签名使增量形状若非刻意为之便无从表达。
-- **同步 `get` 纪律**:提供方一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律,invariant 配套在可行范围内断言同步性;其余由评审把关。
+- **全量值规则是承重结构**:未来某个领域若只记裸增量,就无法凭其最新事件服务消费方,还会让自己的单元复杂化。缓解:该规则写明在本 Note 与投影包的 README 里;单元契约让完整状态在每次转移处都是显式的。
+- **单元的同步纪律**:`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律,invariant 配套在可行范围内断言同步性;其余由评审把关。
+- **忙碌会话上的正向驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,契约不变。
- **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key,模型本身不用改。
- **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。
- **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价;设计台账中的迁移映射一节逐一列出每个 PR 的保留/删除清单。
From 2ebaa30c6d7245e12d5e999bc62d55364516fe20 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 20:57:23 +0800
Subject: [PATCH 20/97] refactor: structured command/run payload {commandId,
name, args, source}
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The line field is deleted (pre-release, no shim): name and args are
parseCommand's own split — name plus verbatim rawInput with its separator
whitespace — so a consumer (a projection unit folding its own command
records, a rich command card) never re-parses a line. CommandNode mirrors
the split (name/args, both null on a run-less cross-window node); the
generic card rebuilds its display line as /name + args. The connection
fixture logs the same structured payload.
---
packages/client/connection/src/client/fixture.ts | 10 ++++++----
.../connection/tests/fixture-commands.spec.ts | 2 +-
.../runtime/src/client/sessions/conversation.ts | 8 ++++----
.../runtime/src/client/sessions/fold-adapter.ts | 6 +++---
packages/client/runtime/tests/event-script.ts | 4 ++--
.../client/runtime/tests/fold-adapter.spec.ts | 16 ++++++++--------
packages/client/runtime/tests/session.spec.ts | 6 +++---
.../src/client/chat/GenericCommandCard.tsx | 7 +++++--
.../ui-conversation/src/client/contract/slots.ts | 3 ++-
.../ui-conversation/tests/chat-view.spec.tsx | 4 ++--
.../apiproxy/tests/api-proxy-commands.spec.ts | 2 +-
packages/ui/commands/README.i18n.yaml | 4 ++--
packages/ui/commands/README.md | 2 +-
packages/ui/commands/README.zh.md | 2 +-
packages/ui/commands/src/index.ts | 11 +++++++----
packages/ui/commands/tests/commands.spec.ts | 2 +-
16 files changed, 49 insertions(+), 40 deletions(-)
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index c1cd17f741..a2add63824 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -815,18 +815,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
- const line = request.payload.line.trim()
- const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
+ // Structured split mirroring the host parser: name + verbatim rawInput
+ // (separator whitespace included) — the run payload carries no line.
+ const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
const name = match?.[1]
+ const args = match?.[2] ?? ''
const outcomes: Record = {
compact: 'fixture:已压缩(假动作)',
- echo: match?.[2] ?? '',
+ echo: args.trim(),
'goal-fixture': `fixture:goal 已设置(${id})`,
}
const text = name === undefined ? undefined : outcomes[name]
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
const commandId = `fx-cmd-${logOf(id).length}`
- append(id, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } })
+ append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
return ok(request, { matched: true as const })
},
diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts
index a71a371973..cd29147b62 100644
--- a/packages/client/connection/tests/fixture-commands.spec.ts
+++ b/packages/client/connection/tests/fixture-commands.spec.ts
@@ -55,7 +55,7 @@ describe('createFixtureApi commands/skills', () => {
.filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event')
.map(f => f.event)
expect(events).toMatchObject([
- { type: 'command/run', data: { name: 'echo', line: '/echo hello world', source: { kind: 'user' } } },
+ { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'hello world' } },
])
expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts
index 16ba778009..8644f6ed40 100644
--- a/packages/client/runtime/src/client/sessions/conversation.ts
+++ b/packages/client/runtime/src/client/sessions/conversation.ts
@@ -126,7 +126,7 @@ export interface UnknownSurfaceNode {
* Log-only events never enter the surface fold, so the FoldAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
- * still builds a node (name/line null), and a run with no done renders as
+ * still builds a node (name/args null), and a run with no done renders as
* still executing.
*/
export interface CommandNode {
@@ -137,10 +137,10 @@ export interface CommandNode {
time: number
/** Pairing id minted by the host executor. */
commandId: string
- /** Command name (run payload); null when the run fell outside the window. */
+ /** Command name (run payload's structured field); null when the run fell outside the window. */
name: string | null
- /** Exact dispatched command line (run payload); null when the run fell outside the window. */
- line: string | null
+ /** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
+ args: string | null
/** Settlement outcome (done payload); null while the command is still executing. */
outcome: { kind: 'success' | 'error'; text?: string } | null
}
diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts
index 8f4b09d72a..635d043525 100644
--- a/packages/client/runtime/src/client/sessions/fold-adapter.ts
+++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts
@@ -229,10 +229,10 @@ export class FoldAdapter {
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
- const data = event.data as unknown as { commandId: string; name: string; line: string }
+ const data = event.data as unknown as { commandId: string; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
- commandId: data.commandId, name: data.name, line: data.line, outcome: null,
+ commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return
}
@@ -245,7 +245,7 @@ export class FoldAdapter {
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
- commandId: data.commandId, name: null, line: null, outcome,
+ commandId: data.commandId, name: null, args: null, outcome,
})
return
}
diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts
index 8611a94f8e..1cb43bd208 100644
--- a/packages/client/runtime/tests/event-script.ts
+++ b/packages/client/runtime/tests/event-script.ts
@@ -42,8 +42,8 @@ export const ev = {
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
at(seq, { type: 'todo/write', data: { todos } }),
- commandRun: (seq: number, commandId: string, name: string, line: string): SessionEvent =>
- at(seq, { type: 'command/run', data: { commandId, name, line, source: { kind: 'user' } } }),
+ commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
+ at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
}
diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts
index 4a9bc4c4d1..88a597063e 100644
--- a/packages/client/runtime/tests/fold-adapter.spec.ts
+++ b/packages/client/runtime/tests/fold-adapter.spec.ts
@@ -148,23 +148,23 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter()
adapter.reset([
ev.user(0, '先说话'),
- ev.commandRun(1, 'cmd-1', 'plan', '/plan'),
+ ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
], 0)
const { nodes } = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
expect(nodes[1]).toMatchObject({
- kind: 'command', commandId: 'cmd-1', name: 'plan', line: '/plan',
+ kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('renders a run with no done as still executing (outcome null)', () => {
const adapter = new FoldAdapter()
- adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', '/goal ship it')], 0)
+ adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({
- kind: 'command', name: 'goal', line: '/goal ship it', outcome: null,
+ kind: 'command', name: 'goal', args: ' ship it', outcome: null,
})
})
@@ -172,7 +172,7 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
expect(adapter.nodes().nodes[0]).toMatchObject({
- kind: 'command', seq: 80, commandId: 'cmd-3', name: null, line: null,
+ kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
outcome: { kind: 'error', text: '失败了' },
})
})
@@ -180,7 +180,7 @@ describe('FoldAdapter', () => {
it('settles a live-appended done in place, keeping the node at the run seq', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
- adapter.append(ev.commandRun(6, 'cmd-4', 'clear', '/clear'))
+ adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
@@ -192,7 +192,7 @@ describe('FoldAdapter', () => {
it('tails command nodes whose seq is past every surface node', () => {
const adapter = new FoldAdapter()
- adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan', '/plan')], 0)
+ adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
})
@@ -201,7 +201,7 @@ describe('FoldAdapter', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
- ev.commandRun(0, 'cmd-5', 'plan', '/plan'),
+ ev.commandRun(0, 'cmd-5', 'plan'),
ev.commandDone(1, 'cmd-5'),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
], 0)
diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts
index 8a7cf0b1c1..383ce0010a 100644
--- a/packages/client/runtime/tests/session.spec.ts
+++ b/packages/client/runtime/tests/session.spec.ts
@@ -103,9 +103,9 @@ describe('live event path', () => {
// Live path: run mints an executing node, done settles it in the flow.
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
- feed(ev.commandRun(6, 'cmd-live', 'plan', '/plan'))
+ feed(ev.commandRun(6, 'cmd-live', 'plan'))
let command = session.getSnapshot().nodes.at(-1)
- expect(command).toMatchObject({ kind: 'command', name: 'plan', line: '/plan', outcome: null })
+ expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
@@ -113,7 +113,7 @@ describe('live event path', () => {
// Replay path (refresh): the same pair inside the history window folds identically.
const replayed = await opened([
...plainTurn(0, 0, 'a', 'b'),
- ev.commandRun(6, 'cmd-live', 'plan', '/plan'),
+ ev.commandRun(6, 'cmd-live', 'plan'),
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
])
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
index c177742975..1dfea5488b 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
@@ -20,12 +20,15 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
+ // Display line rebuilt from the structured payload (args carries its own
+ // separator whitespace verbatim); a cross-window node whose run page fell
+ // out of the window has neither.
+ const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
return (
}
- // A cross-window node whose run page fell out of the window has no line.
- title={node.line ?? '命令'}
+ title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
body={text !== undefined && text.includes('\n') ? text : null}
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 27f8c982f5..cf69f22003 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -168,7 +168,8 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Owner share of the per-command row slot: the frozen {@link CommandNode}
* slice off the snapshot (cache-stable reference — memo premise). The node
- * carries the whole lifecycle (line, pairing id, outcome-or-executing), so a
+ * carries the whole lifecycle (structured name/args, pairing id,
+ * outcome-or-executing), so a
* registrant needs no second data channel; domain state arrives through its
* own projection cell.
*/
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index 4a9c27d9e5..86e13cd45e 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -367,7 +367,7 @@ describe('ChatView', () => {
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
const command = (over: Partial): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1',
- name: 'plan', line: '/plan', outcome: { kind: 'success', text: '已进入 plan mode' },
+ name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the command line is the title, the outcome text the summary.
@@ -394,7 +394,7 @@ describe('ChatView', () => {
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
const orphan = makeHarness({
- nodes: [command({ seq: 8, commandId: 'cmd-4', name: null, line: null, outcome: { kind: 'success' } })],
+ nodes: [command({ seq: 8, commandId: 'cmd-4', name: null, args: null, outcome: { kind: 'success' } })],
})
const ov = render()
expect(ov.getByText('命令')).toBeTruthy()
diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
index f284549335..6cf8799791 100644
--- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
@@ -121,7 +121,7 @@ describe('command.execute', () => {
// lifecycle pair instead of the response.
const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
expect(lifecycle).toMatchObject([
- { type: 'command/run', data: { name: 'goal', line: '/goal ship it' } },
+ { type: 'command/run', data: { name: 'goal', args: ' ship it' } },
{ type: 'command/done', data: { kind: 'success', text: `goal:${agent.id}` } },
])
})
diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml
index 57c17b5823..d8deb56576 100644
--- a/packages/ui/commands/README.i18n.yaml
+++ b/packages/ui/commands/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/ui/commands/README.md
-README.md: db3d06f395fc50c8a6cf5901e42f0b09e083a07e
-README.zh.md: bb9b9d52c2fd0845b0795c37ba0155de319bae28
+README.md: 0a48516cf10902b0a83a8ea12299cc29342ea66d
+README.zh.md: 33ee0e0b3275350008f7bf612471eafa804fb1d0
diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md
index db3d06f395..0a48516cf1 100644
--- a/packages/ui/commands/README.md
+++ b/packages/ui/commands/README.md
@@ -8,7 +8,7 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
-`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the exact line, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
+`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md
index bb9b9d52c2..33ee0e0b32 100644
--- a/packages/ui/commands/README.zh.md
+++ b/packages/ui/commands/README.zh.md
@@ -8,7 +8,7 @@
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
-`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、精确命令行和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
+`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts
index 99f21ef334..645af6a61f 100644
--- a/packages/ui/commands/src/index.ts
+++ b/packages/ui/commands/src/index.ts
@@ -112,10 +112,13 @@ declare module '@deepseek-ai/dsh-session' {
/**
* A resolved slash command entered its handler. Log-only (never model
* surface); paired with `command/done` by `commandId`, mirroring the
- * `tool/call`↔`tool/result` pairing. `line` is the exact command line as
- * dispatched.
+ * `tool/call`↔`tool/result` pairing. The payload is structured — `name`
+ * and `args` are `parseCommand`'s own split (name and verbatim rawInput,
+ * separator whitespace included), so a consumer (a projection unit
+ * folding its own command records, a rich command card) never re-parses
+ * a line.
*/
- 'command/run': { commandId: string; name: string; line: string; source: CommandSource }
+ 'command/run': { commandId: string; name: string; args: string; source: CommandSource }
/**
* The paired command settled. `kind`/`text` carry the handler's verbatim
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
@@ -354,7 +357,7 @@ export class CommandService extends Service {
if (signal.aborted) throw abortError(signal)
const commandId = this.mintCommandId()
await this.appendLifecycle(agent.session, 'command/run', {
- commandId, name: parsed.name, line, source: { kind: 'user' },
+ commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' },
})
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
let result: CommandResult
diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts
index 941db73522..533b2a1c21 100644
--- a/packages/ui/commands/tests/commands.spec.ts
+++ b/packages/ui/commands/tests/commands.spec.ts
@@ -304,7 +304,7 @@ describe('CommandService', () => {
const lifecycle = lifecycleOf(agent)
expect(lifecycle).toMatchObject([
- { type: 'command/run', data: { name: 'deploy', line: '/deploy now', source: { kind: 'user' } } },
+ { type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
])
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
From 6d2e5a7cd7101acce8acc32edbafc61f9759f704 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 21:05:15 +0800
Subject: [PATCH 21/97] feat: command.execute returns the lifecycle pairing id
({matched, commandId?})
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CommandService.execute now returns a CommandExecution — the normalized
result plus the commandId minted for its command/run/command/done records —
and the wire admission value carries commandId exactly when matched, so the
issuing client can correlate its RPC acknowledgment with the flow node the
lifecycle events produce. apiproxy api/schema/handler, the connection
fixture, and the TUI/plan/goal consumers follow the new shape.
---
.../client/connection/src/client/fixture.ts | 2 +-
packages/client/connection/tests/fake-api.ts | 2 +-
.../connection/tests/fixture-commands.spec.ts | 3 ++-
packages/client/runtime/tests/fake-api.ts | 2 +-
.../command-goal/tests/command-goal.spec.ts | 8 +++---
packages/host/apiproxy/README.i18n.yaml | 4 +--
packages/host/apiproxy/README.md | 2 +-
packages/host/apiproxy/README.zh.md | 2 +-
packages/host/apiproxy/src/api-proxy.ts | 10 ++++---
.../host/apiproxy/src/api/commands.schema.ts | 3 ++-
packages/host/apiproxy/src/api/commands.ts | 10 ++++---
.../apiproxy/tests/api-proxy-commands.spec.ts | 7 ++---
.../host/apiproxy/tests/fetch-carrier.spec.ts | 4 +--
.../host/apiproxy/tests/rpc-schemas.spec.ts | 7 +++--
.../plan/plan-mode/tests/plan-mode.spec.ts | 12 ++++-----
packages/ui/commands/README.i18n.yaml | 4 +--
packages/ui/commands/README.md | 2 +-
packages/ui/commands/README.zh.md | 2 +-
packages/ui/commands/src/index.ts | 20 +++++++++++---
packages/ui/commands/tests/commands.spec.ts | 26 +++++++++++--------
packages/ui/tui/src/index.ts | 8 +++---
21 files changed, 85 insertions(+), 55 deletions(-)
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index a2add63824..dded9774cb 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -830,7 +830,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const commandId = `fx-cmd-${logOf(id).length}`
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
- return ok(request, { matched: true as const })
+ return ok(request, { matched: true as const, commandId })
},
},
skills: {
diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts
index b5982e3729..c6e7d65204 100644
--- a/packages/client/connection/tests/fake-api.ts
+++ b/packages/client/connection/tests/fake-api.ts
@@ -94,7 +94,7 @@ export class FakeApiClient implements IApiClient {
// wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ commands: [] }))
- onCommandExecute: (payload: unknown) => Promise>
+ onCommandExecute: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ skills: [] }))
diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts
index cd29147b62..bd66d124a4 100644
--- a/packages/client/connection/tests/fixture-commands.spec.ts
+++ b/packages/client/connection/tests/fixture-commands.spec.ts
@@ -49,7 +49,8 @@ describe('createFixtureApi commands/skills', () => {
})()
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
if (!response.result.ok) throw new Error('execute failed')
- expect(response.result.value).toEqual({ matched: true })
+ expect(response.result.value).toMatchObject({ matched: true })
+ expect(response.result.value.commandId).toBeTruthy()
await pump
const events = frames
.filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event')
diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts
index 5d2b6d96d9..a060125118 100644
--- a/packages/client/runtime/tests/fake-api.ts
+++ b/packages/client/runtime/tests/fake-api.ts
@@ -119,7 +119,7 @@ export class FakeApiClient implements IApiClient {
// skill lists without casts.
onCommandList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ commands: [] }))
- onCommandExecute: (payload: unknown) => Promise>
+ onCommandExecute: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise>
= () => Promise.resolve(ok({ skills: [] }))
diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts
index cc0f681845..d77c64a089 100644
--- a/packages/goal/command-goal/tests/command-goal.spec.ts
+++ b/packages/goal/command-goal/tests/command-goal.spec.ts
@@ -72,14 +72,14 @@ function domainEvents(session: Session): readonly Session['events'][number][] {
}
/** Execute `/goal` through the same registry boundary as a UI adapter. */
-async function run(test: Harness, suffix = ''): Promise>>> {
- const result = await test.ctx.commands.execute(
+async function run(test: Harness, suffix = ''): Promise>>['result']> {
+ const execution = await test.ctx.commands.execute(
test.agent,
`/goal${suffix}`,
new AbortController().signal,
)
- if (result === undefined) throw new Error('goal command was not registered')
- return result
+ if (execution === undefined) throw new Error('goal command was not registered')
+ return execution.result
}
/** Current exact compare-and-set ref. */
diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml
index 3e340567a8..f5b932f3e4 100644
--- a/packages/host/apiproxy/README.i18n.yaml
+++ b/packages/host/apiproxy/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/host/apiproxy/README.md
-README.md: 0e8699e513452030bfa4ffc62737df928c161603
-README.zh.md: 8b19d0357389f616ec8a4120beb2cf8d8d7686d8
+README.md: e450f7081998ce0810fc06ac688fd7214c362363
+README.zh.md: 6658f88ee3b37d1c487abb38456579ddaaba4b61
diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md
index 0e8699e513..e450f70819 100644
--- a/packages/host/apiproxy/README.md
+++ b/packages/host/apiproxy/README.md
@@ -20,7 +20,7 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
-The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports only whether the line resolved to a handler, while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
+The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md
index 8b19d03573..6658f88ee3 100644
--- a/packages/host/apiproxy/README.zh.md
+++ b/packages/host/apiproxy/README.zh.md
@@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
-`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应只报告该行是否解析到处理器,结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
+`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)
diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index ad0f8fc8e4..92ce284dd4 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -921,9 +921,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
try {
// Pure admission: the executor's durable command/run + command/done
// pair (broadcast on the mux stream) carries the outcome; the
- // response only reports whether the line resolved to a handler.
- const result = await commands.execute(found.agent, line, signal)
- return ok(request, { matched: result !== undefined })
+ // response reports whether the line resolved to a handler, plus the
+ // minted pairing id so the issuing client can correlate its request
+ // with the flow node the lifecycle events produce.
+ const execution = await commands.execute(found.agent, line, signal)
+ return ok(request, execution === undefined
+ ? { matched: false }
+ : { matched: true, commandId: execution.commandId })
} catch (error: unknown) {
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts
index ba0c5a8e0e..9d2acb7c20 100644
--- a/packages/host/apiproxy/src/api/commands.schema.ts
+++ b/packages/host/apiproxy/src/api/commands.schema.ts
@@ -32,7 +32,8 @@ export const commandExecuteRequestSchema = z.object({
line: z.string(),
}) satisfies z.ZodType>>
-/** command.execute response value: pure admission — outcomes ride the logged lifecycle events, never this response. */
+/** command.execute response value: pure admission — outcomes ride the logged lifecycle events; commandId (present exactly when matched) correlates with them. */
export const commandExecuteValueSchema = z.object({
matched: z.boolean(),
+ commandId: z.string().min(1).optional(),
}) satisfies z.ZodType>>
diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts
index 08d25a4dec..933e753797 100644
--- a/packages/host/apiproxy/src/api/commands.ts
+++ b/packages/host/apiproxy/src/api/commands.ts
@@ -36,10 +36,12 @@ export interface CommandsApi {
* when syntax or name does not resolve (the client falls back to its
* default sink). The handler's outcome does NOT ride the response: the host
* executor durably logs the lifecycle (`command/run`/`command/done`), which
- * broadcasts on the mux stream and renders as a persistent flow node. The
- * signal rides beside the request, never on the wire: the fetch carrier's
- * request signal cancels the running handler.
+ * broadcasts on the mux stream and renders as a persistent flow node.
+ * `commandId` is present exactly when matched — the minted lifecycle
+ * pairing id, letting the issuing client correlate this acknowledgment
+ * with that flow node. The signal rides beside the request, never on the
+ * wire: the fetch carrier's request signal cancels the running handler.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
- Promise>
+ Promise>
}
diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
index 6cf8799791..e09551ebb2 100644
--- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
@@ -115,14 +115,15 @@ describe('command.execute', () => {
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
- expect(value).toEqual({ matched: true })
+ expect(value).toMatchObject({ matched: true })
+ expect(value.commandId).toBeTruthy()
expect(received).toBe(' ship it')
// Pure admission on the wire: the outcome rides the durably logged
// lifecycle pair instead of the response.
const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
expect(lifecycle).toMatchObject([
- { type: 'command/run', data: { name: 'goal', args: ' ship it' } },
- { type: 'command/done', data: { kind: 'success', text: `goal:${agent.id}` } },
+ { type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } },
+ { type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } },
])
})
diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
index d4d146294b..00c4166849 100644
--- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts
+++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
@@ -91,7 +91,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
}
if (request.payload.line.startsWith('/plan')) {
- return { rpcId: request.rpcId, result: { ok: true, value: { matched: true } } }
+ return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: 'cmd-x' } } }
}
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
},
@@ -163,7 +163,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const list = await c.commands.list({ sessionId: 's' as never })
expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
- expect(hit.result).toEqual({ ok: true, value: { matched: true } })
+ expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } })
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
const skills = await c.skills.list({ sessionId: 's' as never })
diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
index 669bef3e45..0459d1d62c 100644
--- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts
+++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
@@ -215,9 +215,12 @@ describe('commands domain schemas', () => {
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
- // Pure admission: the value carries only the matched bit (outcomes ride
- // the logged lifecycle events, never this response).
+ // Pure admission: matched plus the optional lifecycle pairing id
+ // (outcomes ride the logged lifecycle events, never this response).
+ expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' }))
+ .toEqual({ matched: true, commandId: 'cmd-1' })
expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
+ expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow()
expect(() => commandExecuteValueSchema.parse({})).toThrow()
})
})
diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts
index bcc88920a8..dec49129e8 100644
--- a/packages/plan/plan-mode/tests/plan-mode.spec.ts
+++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts
@@ -505,7 +505,7 @@ describe('/plan', () => {
expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
- expect(plain).toEqual({
+ expect(plain?.result).toEqual({
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
})
@@ -516,7 +516,7 @@ describe('/plan', () => {
const messageSteer = vi.fn()
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
- expect(plan).toEqual({
+ expect(plan?.result).toEqual({
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
})
@@ -535,7 +535,7 @@ describe('/plan', () => {
const signal = new AbortController().signal
const inactive = await agentWithSession(ctx, 'inactive-plan-command')
- expect(await ctx.commands.execute(inactive, '/plan off', signal))
+ expect((await ctx.commands.execute(inactive, '/plan off', signal))?.result)
.toEqual({ kind: 'success', text: 'Plan mode is already inactive.' })
expect(ctx.planMode.get(inactive)).toEqual({ active: false })
@@ -543,7 +543,7 @@ describe('/plan', () => {
const enteringSteer = vi.fn()
;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
await ctx.commands.execute(entering, '/plan', signal)
- expect(await ctx.commands.execute(entering, '/plan off', signal))
+ expect((await ctx.commands.execute(entering, '/plan off', signal))?.result)
.toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
expect(enteringSteer).not.toHaveBeenCalled()
@@ -554,10 +554,10 @@ describe('/plan', () => {
const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
const activeSteer = vi.fn()
;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
- expect(await ctx.commands.execute(active, '/plan off', signal))
+ expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false })
- expect(await ctx.commands.execute(active, '/plan off', signal))
+ expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(activeSteer).not.toHaveBeenCalled()
await boundary(ctx, active, 'step/end')
diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml
index d8deb56576..67b8d50dfd 100644
--- a/packages/ui/commands/README.i18n.yaml
+++ b/packages/ui/commands/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/ui/commands/README.md
-README.md: 0a48516cf10902b0a83a8ea12299cc29342ea66d
-README.zh.md: 33ee0e0b3275350008f7bf612471eafa804fb1d0
+README.md: 139a21857b41c7e352ee6a0746e4959b218881e8
+README.zh.md: 466c02ab3699b26e5c946b3442c28e6f0fc93d89
diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md
index 0a48516cf1..139a21857b 100644
--- a/packages/ui/commands/README.md
+++ b/packages/ui/commands/README.md
@@ -8,7 +8,7 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
-`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
+`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is durably logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Lifecycle appends are serialized per session through `SessionStore.appendOutOfBand`, so the service requires a composed `sessions` service.
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md
index 33ee0e0b32..466c02ab36 100644
--- a/packages/ui/commands/README.zh.md
+++ b/packages/ui/commands/README.zh.md
@@ -8,7 +8,7 @@
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
-`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
+`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式持久记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。生命周期落账通过 `SessionStore.appendOutOfBand` 按会话串行化,因此本服务要求组合中存在 `sessions` 服务。
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts
index 645af6a61f..3f3ed6f037 100644
--- a/packages/ui/commands/src/index.ts
+++ b/packages/ui/commands/src/index.ts
@@ -47,6 +47,19 @@ export type CommandResult =
| { readonly kind: 'success'; readonly text?: string }
| { readonly kind: 'error'; readonly text: string }
+/**
+ * One settled command execution: the handler's normalized result plus the
+ * lifecycle pairing id minted for its `command/run`/`command/done` records,
+ * so a dispatching surface can correlate the RPC-level acknowledgment with
+ * the flow node those events produce.
+ */
+export interface CommandExecution {
+ /** Pairing id carried by this execution's lifecycle events. */
+ readonly commandId: string
+ /** The handler's normalized outcome. */
+ readonly result: CommandResult
+}
+
/** Plugin-owned command registration. */
export interface CommandDefinition {
/** Lowercase command name without the leading slash. */
@@ -343,13 +356,14 @@ export class CommandService extends Service {
* @param agent - exact receiving agent.
* @param line - complete slash-command line.
* @param signal - cancellation signal owned by the UI request.
- * @returns a detached result, or `undefined` when syntax or name does not resolve.
+ * @returns the settled execution (result + lifecycle pairing id), or
+ * `undefined` when syntax or name does not resolve.
*/
async execute(
agent: Agent,
line: string,
signal: AbortSignal,
- ): Promise {
+ ): Promise {
const parsed = parseCommand(line)
if (parsed === undefined) return undefined
const command = this.view(agent).get(parsed.name)
@@ -379,7 +393,7 @@ export class CommandService extends Service {
commandId, kind: result.kind,
...result.text === undefined ? {} : { text: result.text },
})
- return result
+ return Object.freeze({ commandId, result })
}
/** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts
index 533b2a1c21..901f6f9fb7 100644
--- a/packages/ui/commands/tests/commands.spec.ts
+++ b/packages/ui/commands/tests/commands.spec.ts
@@ -96,11 +96,11 @@ describe('CommandService', () => {
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
- expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
+ expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result)
.toEqual({ kind: 'success', text: 'scoped' })
await scope.dispose()
- expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
+ expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global')
})
it('removes a registration when its contributing plugin fiber is disposed', async () => {
@@ -176,10 +176,12 @@ describe('CommandService', () => {
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
const controller = new AbortController()
- const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
+ const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
- expect(result).toEqual({ kind: 'success', text: 'ok' })
- expect(Object.isFrozen(result)).toBe(true)
+ expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
+ expect(execution?.commandId).toBeTruthy()
+ expect(Object.isFrozen(execution)).toBe(true)
+ expect(Object.isFrozen(execution?.result)).toBe(true)
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
agent,
rawInput: ' untouched ',
@@ -271,9 +273,9 @@ describe('CommandService', () => {
description: 'Denied',
handler: () => ({ kind: 'error', text: 'not now' }),
})
- const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
- expect(result).toEqual({ kind: 'error', text: 'not now' })
- expect(Object.isFrozen(result)).toBe(true)
+ const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
+ expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
+ expect(Object.isFrozen(execution?.result)).toBe(true)
ctx.commands.register({
name: 'silent',
@@ -281,8 +283,8 @@ describe('CommandService', () => {
handler: () => ({ kind: 'success' }),
})
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
- expect(silent).toEqual({ kind: 'success' })
- expect(Object.isFrozen(silent)).toBe(true)
+ expect(silent?.result).toEqual({ kind: 'success' })
+ expect(Object.isFrozen(silent?.result)).toBe(true)
})
it.each([
@@ -300,7 +302,7 @@ describe('CommandService', () => {
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('deploy', 'deployed'))
- await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
+ const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
const lifecycle = lifecycleOf(agent)
expect(lifecycle).toMatchObject([
@@ -310,6 +312,8 @@ describe('CommandService', () => {
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
expect(ids[0]).toBeTruthy()
expect(ids[0]).toBe(ids[1])
+ // The execution's pairing id is the logged one (RPC-level correlation).
+ expect(execution?.commandId).toBe(ids[0])
// Zero-step wrap: the pair stays turn-enclosed on an idle log.
expect(agent.session.events.map(event => event.type)).toEqual([
'turn/start', 'command/run', 'turn/end',
diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts
index 3f13fe89e4..e153f5e6dc 100644
--- a/packages/ui/tui/src/index.ts
+++ b/packages/ui/tui/src/index.ts
@@ -2872,12 +2872,12 @@ export function createTuiChat(
const controller = new AbortController()
commandControllers.add(controller)
void ctx.commands.execute(agent, text, controller.signal).then(
- (result) => {
+ (execution) => {
if (disposed) return
- if (result === undefined) {
+ if (execution === undefined) {
appendNotice(`Unknown command: ${text}`, 'warning')
- } else if (result.text !== undefined && result.text !== '') {
- appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
+ } else if (execution.result.text !== undefined && execution.result.text !== '') {
+ appendNotice(execution.result.text, execution.result.kind === 'error' ? 'error' : 'info')
}
},
(error: unknown) => {
From 708d3132cfb7c4b7982ded2718a27db968aabac9 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 21:34:14 +0800
Subject: [PATCH 22/97] feat: reshape dsh-session-projection to state-driven
units with eager drive
---
.../session-projection/README.md | 30 ++-
.../session-projection/package.json | 4 +-
.../session-projection/src/index.ts | 244 ++++++++++++++----
.../session-projection/src/invariant.ts | 17 +-
.../session-projection/tests/registry.spec.ts | 213 +++++++++++----
.../session-projection/tsconfig.json | 2 +-
pnpm-lock.yaml | 6 +-
7 files changed, 387 insertions(+), 129 deletions(-)
diff --git a/packages/session-projection/session-projection/README.md b/packages/session-projection/session-projection/README.md
index af7c9622bb..272c0bf93a 100644
--- a/packages/session-projection/session-projection/README.md
+++ b/packages/session-projection/session-projection/README.md
@@ -1,33 +1,37 @@
# @deepseek-ai/dsh-session-projection
-Session-projection seam. It owns `ctx.sessionProjections`, the registry through which a domain host plugin serves the whole current value of its log-derived per-session state, and through which a carrier (the api-proxy history tail page today; TUI/ACP/headless consumers later) reads every registered value in one synchronous, seq-consistent cut. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
+Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRIVES every registered projection unit forward over committed session events and serves finished whole values to carriers (the api-proxy history tail page and `session/projection` push frame today; TUI/ACP/headless consumers later). A domain registers pure mathematics; the framework owns the drive. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
## Service: `SessionProjectionRegistry` (ctx key: `sessionProjections`)
### Public API
-- `ctx.sessionProjections.register(provider): () => void` Register one domain's provider. Duplicate keys throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key disappears from subsequent walks (clients read that as capability absence).
-- `ctx.sessionProjections.entries(): AnyProjectionProvider[]` Snapshot the registered providers in registration order — the carrier walk surface.
+- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence.
+- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
+- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log).
### Key Types
-- `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host provider, wire block, client cell, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
-- `ProjectionProvider