Merge remote-tracking branch 'origin/master' into nih-imp-execa

# Conflicts:
#	.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml
#	apps/web/tests/scaffold.ts
#	packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts
#	packages/support/acp-snapshot/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-27 06:31:17 +08:00
665 changed files with 11055 additions and 7377 deletions

View File

@@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.

View File

@@ -60,6 +60,7 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",

View File

@@ -535,7 +535,7 @@ export function apply(ctx: Context, config: Config = {}): void {
if (tasks === undefined) {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// The caller owns cancellation until TaskService commits detached ownership.
// The caller owns cancellation until ctx.tasks commits detached ownership.
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'

View File

@@ -8,7 +8,7 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import TaskService from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
@@ -27,7 +27,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' })
}
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
@@ -169,7 +169,7 @@ describe('bash tool through the agent loop', () => {
})
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
// The task id is deterministic (a fresh TaskService counts per kind from 1),
// The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
// so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),

View File

@@ -12,7 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import TaskService from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
@@ -44,7 +44,7 @@ async function setupWithTasks() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
@@ -180,7 +180,7 @@ async function setupSandboxed(withApproval = false) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(RecordingSandboxExecutor)
@@ -472,7 +472,7 @@ describe('background execution through the task runtime', () => {
})
it('fails loud when the task runtime is not loaded', async () => {
const ctx = await setup() // no TaskService / ToolTasks
const ctx = await setup() // no LocalTaskService / ToolTasks
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
@@ -483,7 +483,7 @@ describe('background execution through the task runtime', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(CountingStartExecutor)
await ctx.plugin(ToolBash)
@@ -511,7 +511,7 @@ describe('background execution through the task runtime', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(CountingStartExecutor)
await ctx.plugin(ToolBash)
@@ -1073,7 +1073,7 @@ describe('the model-facing bash tool builds its request from named args only (no
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
}
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })

View File

@@ -124,6 +124,49 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
// keyed registration a top-level bash row uses).
{
const turn = 64
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'return { listing, demo }'
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}run_code 样本。`), source: { kind: 'user' } } })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
type: 'assistant/message', surfaceOp: 'append',
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
push({
type: 'tool/code-dispatch-start',
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
})
push({
type: 'tool/code-dispatch',
data: {
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
},
})
}
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
push({
type: 'tool/result', surfaceOp: 'append',
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
})
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
return events as unknown as SessionEvent[]
}

View File

@@ -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
README.md: 5d44f666a12e747eb79535c48d87fcaff381d770
README.zh.md: 16577c356ba79066c7c56ae07a266f4ec85fa2e9
README.md: 7776a5c2cf1d0990c9c339c6e5fc66401f935810
README.zh.md: 8a0b7394c07878b8de958eae43d11203c92b5827

View File

@@ -14,6 +14,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped.
## Code Mode sub-dispatch index
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
## Session title projection
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.

View File

@@ -14,6 +14,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId。失败时抛出 `SessionCreateError`:传输状态不确定后仍可取得 `requestedSessionId`;如果 Host 在附加失败前已经发布真实 Session则会设置 `publishedSessionId`,此时 `workspace-attach-failed` 提供了证明。在 New Session 流程中,前端 Session 对象拥有其保留的提示词,并推动提示词完成附加与发送;部分发布的 Session 会保留同一对象和提示词,同时显示为 Ungrouped。
## Code Mode 子调用索引
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
## Session 标题投影
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值直到打开或恢复会话促使主机折叠并投影日志支持的标题。

View File

@@ -24,7 +24,7 @@ export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'

View File

@@ -127,6 +127,21 @@ export type ConversationNode =
| ToolResultNode
| UnknownSurfaceNode
/**
* One `run_code` sub-dispatch materialized in the native call-block shapes so
* every consumer (tool rows, details panel) renders it through the exact
* components that render a native call: a started-but-unsettled sub-call is a
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the surface `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
* settled sub-call's complete logged outcome.
*/
export type CodeSubCall = RunningToolCall | ToolResultNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
@@ -212,6 +227,13 @@ export interface ConversationSnapshot {
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
* order. Populated from in-window `tool/code-dispatch` events (live and
* replay identically); the per-parent array reference is stable across
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
running: boolean
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */

View File

@@ -11,7 +11,7 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
@@ -66,6 +66,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -611,6 +616,65 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
// the host-side dsh-tools plugin whose types cannot enter the client
// program (its host Context merges collide with the client's), so this
// wire consumer narrows them structurally — the same posture as every
// other cross-wire event payload.
if ((event.type as string) === 'tool/code-dispatch-start') {
// A started sub-dispatch enters the index as a RunningToolCall — the
// exact shape a native in-flight call renders from — under its parent
// run_code callId; it never joins the surface flow.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: CodeSubCall = {
callId: data.subCallId, name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
return
}
if ((event.type as string) === 'tool/code-dispatch') {
// Settlement replaces the running entry in place (same array position,
// so parallel sub-calls keep their start order) with the
// ToolResultNode form; a settle with no observed start (history window
// cut mid-pair, or a pre-start-event log) appends directly.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
// Duration source: the paired start's time when observed; null =
// unknown (settle-only window), matching the native tool-result
// contract so views never present a fabricated zero duration.
callTime: started === undefined ? null : started.time,
content: data.content, isError: data.isError,
callView: null, resultView: null,
}
this.codeDispatches.set(
data.parentCallId,
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
)
this.dispatchesRev++
return
}
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
@@ -690,6 +754,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++
this.frozenNodes = []
this.frozenRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -722,6 +788,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
@@ -730,6 +799,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,

View File

@@ -26,6 +26,16 @@ export const ev = {
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch-start',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
}),
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>

View File

@@ -644,6 +644,95 @@ describe('resync', () => {
})
})
describe('run_code sub-dispatch indexing', () => {
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
const live = session.getSnapshot().codeDispatches.get('p1')
expect(live).toHaveLength(2)
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
const mixed = session.getSnapshot().codeDispatches.get('p1')
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
// The settle carries the paired start's time as callTime (duration source).
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
const settled = session.getSnapshot().codeDispatches.get('p1')
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
})
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' },
// The settle event carries no start time: callTime stays null (never a
// fabricated zero-duration).
callTime: null,
isError: false, content: [{ type: 'text', text: 'demo.txt' }],
})
expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true })
// No paired start in the window: duration is UNKNOWN (null), never a
// fabricated zero-duration span.
expect(subs?.[0]).toMatchObject({ callTime: null })
// Sub-dispatches never join the surface flow.
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
})
it('rebuilds the same index from a history window (replay parity)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([
...plainTurn(0, 0, '问', '答'),
ev.turnStart(6, 1),
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
ev.toolResult(9, 1, 'p1', '{"done":true}'),
ev.turnEnd(10, 1),
])
await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
})
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot()
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
})
})
describe('reference stability (the memo contract)', () => {
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
const { api, session } = makeSession()

View File

@@ -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
README.md: f5e2603e8ad588740bea3be4f1a195d658721f0c
README.zh.md: 900d3d1608da3c86078dc56ec819b1f851e63de7
README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467
README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311

View File

@@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).

View File

@@ -8,7 +8,7 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write 和 edit 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要,同时保留共享的行到详情交互。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。

View File

@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} />
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null

View File

@@ -45,6 +45,18 @@
outline-offset: 1px;
}
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
the code turn reads as one unit; each nested row is itself a .callRow
(same components, same selection outline as top-level rows). */
.subCalls {
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0 2px 22px;
padding-left: 8px;
border-left: 1px solid var(--dsw-alias-border-l2);
}
.hint {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;

View File

@@ -20,7 +20,7 @@ import {
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CodeSubCall, 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'
@@ -45,10 +45,39 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
renderSlot: RenderToolRow
node: CodeSubCall
onOpenDetails: OpenDetails
selected: boolean
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, onOpenDetails])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
</div>
)
})
/** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: {
* GenericToolCard at this render site. A `run_code` call additionally
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: {
renderSlot: RenderToolRow
callId: string
toolName: string
@@ -57,6 +86,10 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
seq: number
onOpenDetails: OpenDetails
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined
}) {
const owner = useMemo(() => ({
callId, toolName, block,
@@ -68,17 +101,32 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
{subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls>
{subCalls.map((node) => (
<SubCallRow
key={node.callId}
renderSlot={renderSlot}
node={node}
onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId}
/>
))}
</div>
)}
</div>
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
/** Only set when the selected call lives in THIS group (memo economy). */
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}) {
return (
<div className={css.toolGroup}>
@@ -92,6 +140,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
seq={node.seq}
onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
/>
))}
</div>
@@ -116,6 +166,7 @@ function StreamingTail({ useSession, onGrow }: {
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const codeDispatches = useSession((s) => s.codeDispatches)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
@@ -203,7 +254,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
&& item.results.some((r) => r.callId === selectedCallId
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
return (
<ToolGroup
key={item.key}
@@ -211,6 +263,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
results={item.results}
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
/>
)
}
@@ -250,6 +303,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
seq={call.turn}
onOpenDetails={openDetails}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
/>
))}
</div>

View File

@@ -6,7 +6,7 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -21,6 +21,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
code: <IconCodeOutline16 />,
others: <IconSparkle16 />,
}

View File

@@ -86,3 +86,10 @@ button.leading {
word-break: break-word;
color: var(--dsw-alias-label-tertiary);
}
/* The code variant's expanded body is the run_code program, rendered through
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
this row's concern. */
.codeBody {
margin: 4px 0 4px 22px;
}

View File

@@ -6,7 +6,7 @@
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import css from './ToolRow.module.css'
@@ -96,7 +96,9 @@ export function ToolRow({
</>
)}
</div>
{open && <div className={css.body}>{body}</div>}
{open && (variant === 'code'
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{body}</div>)}
</div>
)
}

View File

@@ -13,8 +13,8 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** The frozen slice the chat view hands to toolview components as `block`
* (both members are cache-stable references off ConversationSnapshot). */
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
/** The eight row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
@@ -22,7 +22,7 @@ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Figma row titles per variant (design literals, not translatable copy). */
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
write: 'Write', edit: 'Edit', others: 'Tool call',
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
}
/** Known tool name -> variant. */
@@ -35,6 +35,7 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
glob: 'search',
write: 'write',
edit: 'edit',
run_code: 'code',
}
/**
@@ -86,6 +87,7 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
think: [],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
code: ['description'],
others: [],
}
@@ -101,10 +103,17 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
return firstLine(argsRaw)
}
function deriveBody(argsRaw: string): string | null {
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
if (argsRaw === '') return null
const parsed = parseArgs(argsRaw)
return parsed === undefined ? argsRaw : JSON.stringify(parsed, null, 2)
if (parsed === undefined) return argsRaw
// The code row's expanded body IS the program (monospace via the row's
// variant styling), not the args JSON envelope around it.
if (variant === 'code' && typeof parsed === 'object' && parsed !== null) {
const code = (parsed as Record<string, unknown>).code
if (typeof code === 'string' && code !== '') return code
}
return JSON.stringify(parsed, null, 2)
}
/**
@@ -128,7 +137,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
variant,
title: VARIANT_TITLES[variant],
summary,
body: deriveBody(argsRaw),
body: deriveBody(variant, argsRaw),
state,
}
}

View File

@@ -5,6 +5,7 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
@@ -31,6 +32,18 @@ function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | nu
if (open !== undefined) {
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
}
// run_code sub-dispatches: the native call-block shapes, so a selected
// sub-row resolves through the same material as a native call — the
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
for (const subs of s.codeDispatches.values()) {
for (const sub of subs) {
if (sub.callId !== callId) continue
if ('kind' in sub) {
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
}
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
}
}
return null
}
@@ -77,7 +90,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Input</div>
<pre className={css.code}>{pretty(material.argsRaw)}</pre>
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
</section>
)}
<section className={css.section}>

View File

@@ -0,0 +1,234 @@
// @vitest-environment jsdom
// Code Mode sub-call acceptance on the REAL machinery stack (same bench as
// chat-toolview-slot.spec): a run_code result renders the 'code' variant row
// (description summary, program body), its logged sub-dispatches render as
// always-visible nested rows through the SAME keyed toolview hole — the bash
// sub-call lands in the bash sample plugin's registration exactly like a
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
// and a sub-row click opens details for the sub-callId. Running parents
// (runningCalls) nest their so-far dispatches the same way.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
const RUN_CODE_ARGS = JSON.stringify({ code: PROGRAM, description: 'List the notes directory' })
const codeResult = (seq: number, callId: string): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
})
const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
})
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
})
function snapshotWith(
nodes: ToolResultNode[],
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>,
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
pending: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
}
/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */
async function bench(snapshot: ConversationSnapshot) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
current: SID,
intent: undefined,
phase: 'ready',
})
const cell = { sessionId: SID, session }
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, layout }
}
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
}
describe('run_code sub-calls through the real chat machinery', () => {
it('renders the code-variant parent row with the description summary and nested sub-rows', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
// Parent row: the code variant with the model-authored description.
const codeRoot = view.container.querySelector('[data-variant="code"]')
expect(codeRoot).not.toBeNull()
expect(view.getByText('Code')).toBeTruthy()
expect(view.getByText('List the notes directory')).toBeTruthy()
// Nested rows are ALWAYS visible (no parent expand needed): the bash
// sub-call landed in the bash sample plugin's keyed registration — the
// exact component a native top-level bash row uses — and the unregistered
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
// The code row is expandable via its leading control (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
// Shiki splits the program into token spans inside one <pre class="shiki">:
// assert the whole text and the highlighted tree rather than one node.
const pre = view.container.querySelector('pre.shiki')
expect(pre).not.toBeNull()
expect(pre!.textContent).toContain('const listing = await tools.bash')
expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
})
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
expect(nested).not.toBeNull()
})
it('a sub-row click opens details for the sub-callId', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
view.getByText('List notes').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
})
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
const parent = 'call-live'
const dispatches = new Map([[parent, [
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
expect(running).not.toBeNull()
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: CodeSubCall = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
turn: 0, step: 0, time: 21_000, callView: null,
}
const dispatches = new Map([[parent, [runningSub]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
// same StateDot ring a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})
it('an ordinary tool row renders no sub-call nest', async () => {
const parent = 'call-64'
const plain: ToolResultNode = {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, callView: null, resultView: null,
}
const b = await bench(snapshotWith([plain], new Map()))
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
})
})

View File

@@ -26,7 +26,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
}

View File

@@ -39,7 +39,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
} as ConversationSnapshot

View File

@@ -28,7 +28,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
}

View File

@@ -18,7 +18,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
} as ConversationSnapshot
@@ -84,4 +84,40 @@ describe('render branch tails', () => {
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
localStorage.clear()
const snap = snapshotBase()
const longText = 'x'.repeat(1_000)
snap.codeDispatches = new Map([['p1', [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_000,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
}]]])
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
// and the COMPLETE logged output renders (no truncation anywhere).
expect(view.getByText('read')).toBeTruthy()
expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy()
expect(view.getByText(longText)).toBeTruthy()
})
})

View File

@@ -119,7 +119,7 @@ function conversationSnapshot(
pendingPrompt: ConversationSnapshot['pendingPrompt'] = null,
): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
}

View File

@@ -20,11 +20,13 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@shikijs/langs": "^4.3.1",
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"shiki": "^4.3.1"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -17,6 +17,7 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'

View File

@@ -0,0 +1,27 @@
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
and the fallback <pre> draw identically except for token colors. */
.block :where(pre) {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
overflow-x: auto;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
}
/* Shiki inlines its theme background var; route it to the repo token. */
.block :where(pre.shiki) {
background: var(--dsw-alias-markdown-code-block) !important;
}
.block :where(pre) code {
font: inherit;
background: none;
padding: 0;
}
.plain {
color: var(--dsw-alias-label-primary);
white-space: pre;
}

View File

@@ -0,0 +1,37 @@
// CodeBlock: one code surface for every consumer — markdown fences, the
// run_code program body, and the details panel's raw args/output — with
// shiki highlighting for the registered grammars and an identical-geometry
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
// tree of nested spans whose colors are --shiki-* custom properties
// (token sheets own the values); it produces no scripts or event handlers,
// so injecting its output is safe by construction.
import { useMemo } from 'react'
import clsx from 'clsx'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
/** The source text, rendered verbatim (trailing newline trimmed for display). */
code: string
/** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
lang?: string | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
if (html === undefined) {
return (
<div className={clsx(css.block, className)}>
<pre className={css.plain}><code>{trimmed}</code></pre>
</div>
)
}
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
}

View File

@@ -1,6 +1,8 @@
import { isValidElement } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from './CodeBlock.tsx'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
@@ -22,7 +24,9 @@ function sanitizeUrl(url: string): string {
const safeUrl: UrlTransform = url => sanitizeUrl(url)
const components: Components = {
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean): Components {
return {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
@@ -42,19 +46,40 @@ const components: Components = {
<table>{children}</table>
</div>
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
// languages); inline code keeps the default <code> path (the :not(pre)
// rule styles it). While the message streams, the fence renders the
// plain arm — retokenizing a growing fence on every chunk is quadratic
// main-thread work; the finalize swap highlights it once.
pre: ({ children }) => {
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence)
// keeps the stock <pre> rather than guessing.
if (typeof raw !== 'string') return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
},
}
}
const staticComponents = buildComponents(false)
const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences plain (highlighting lands on the finalize swap).
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text }: { text: string }) {
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={components}
components={streaming ? streamingComponents : staticComponents}
urlTransform={safeUrl}
>
{text}

View File

@@ -0,0 +1,82 @@
/**
* The client's ONE syntax highlighter: a synchronous fine-grained shiki core
* (JavaScript regex engine — no oniguruma WASM, bundle-friendly) with an
* explicit grammar allowlist and a CSS-variables theme. Colors live in the
* theme package's token sheets as `--shiki-*` custom properties (light and
* dark blocks), never here — the repo's tokens-only styling rule.
*
* Grammars are the set the harness actually renders: TypeScript programs
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
* and JSON payloads. An unknown or absent language falls back to plain text
* (no highlighting, still monospace) — never an error.
*/
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
import type { HighlighterCore } from 'shiki/core'
/**
* Language ids (and aliases) the singleton registers; everything else renders
* plain. A Map, not an object: fence info strings are assistant-authored, so
* a label like `constructor` or `__proto__` must miss instead of resolving an
* inherited property and crashing the renderer inside shiki.
*/
const LANG_ALIASES = new Map<string, string>([
['typescript', 'typescript'],
['ts', 'typescript'],
['tsx', 'typescript'],
['javascript', 'typescript'],
['js', 'typescript'],
['shellscript', 'shellscript'],
['bash', 'shellscript'],
['sh', 'shellscript'],
['shell', 'shellscript'],
['zsh', 'shellscript'],
['json', 'json'],
['jsonc', 'json'],
])
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
const cssVariablesTheme = createCssVariablesTheme({
name: 'css-variables',
variablePrefix: '--shiki-',
fontStyle: true,
})
let singleton: HighlighterCore | undefined
/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */
function highlighter(): HighlighterCore {
singleton ??= createHighlighterCoreSync({
themes: [cssVariablesTheme],
langs: [langTs, langBash, langJson],
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
return singleton
}
// Engine + grammar construction costs a long task (~120-175ms); building it
// during the first finalized fence's render would jank exactly when a stream
// completes. Warm the singleton in a deferred task at module load (= plugin
// boot) instead; the lazy path above stays as the correctness fallback for a
// fence that renders before the timer fires. `unref` (Node-only) keeps a
// non-browser import from pinning the event loop.
const warmupTimer = setTimeout(() => { highlighter() }, 0)
;(warmupTimer as { unref?: () => void }).unref?.()
/**
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
* when `lang` maps to a registered grammar; `undefined` means the caller
* renders its plain fallback.
* @param code - the source text.
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
* @returns the highlighted HTML, or `undefined` for unknown languages.
*/
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
// CodeBlock + the shiki singleton: registered grammars highlight into token
// spans colored by --shiki-* custom properties; unknown/absent languages take
// the identical-geometry plain arm; aliases resolve; the trailing newline is
// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
// alongside the rest of the markdown family.
import { describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { highlightToHtml } from '../src/markdown/highlight.ts'
afterEach(cleanup)
describe('highlightToHtml', () => {
it('highlights a registered grammar into css-variables token spans', () => {
const html = highlightToHtml('const x: number = 1', 'typescript')
expect(html).toContain('pre class="shiki css-variables"')
expect(html).toContain('var(--shiki-')
})
it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
expect(highlightToHtml('x', alias)).toContain('shiki')
})
it('returns undefined for unknown or absent languages', () => {
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
expect(highlightToHtml('x', undefined)).toBeUndefined()
})
})
describe('CodeBlock', () => {
it('renders the highlighted tree for TypeScript', () => {
const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
const pre = view.container.querySelector('pre.shiki')
expect(pre).not.toBeNull()
expect(pre!.textContent).toBe('const a = 1')
expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
})
it('renders the plain arm for an unknown language with the text verbatim', () => {
const view = render(<CodeBlock code={'IDENTIFICATION DIVISION.'} lang="cobol" />)
expect(view.container.querySelector('pre.shiki')).toBeNull()
expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
})
it('renders the plain arm when no language is given', () => {
const view = render(<CodeBlock code="plain text" />)
expect(view.container.querySelector('pre.shiki')).toBeNull()
expect(view.getByText('plain text')).toBeTruthy()
})
})

View File

@@ -57,11 +57,41 @@ describe('MarkdownText', () => {
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
expect(container.querySelector('hr')).not.toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
// The ts fence routed through the shared CodeBlock: shiki token spans present.
expect(container.querySelector('pre.shiki')).not.toBeNull()
expect(container.querySelector('br')).not.toBeNull()
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
})
it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
expect(container.querySelector('pre.shiki')).toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('code body')
unmount()
}
})
it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
const empty = render(<MarkdownText text={'```\n```'} />)
expect(empty.container.querySelector('pre')?.outerHTML).toBe('<pre><code></code></pre>')
const plain = render(<MarkdownText text={'```\nno language here\n```'} />)
expect(plain.container.querySelector('pre.shiki')).toBeNull()
expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here')
})
it('streaming renders fences plain; the finalize swap highlights them', () => {
const fence = '```ts\nconst answer = 42\n```'
const live = render(<MarkdownText text={fence} streaming />)
expect(live.container.querySelector('pre.shiki')).toBeNull()
expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42')
live.unmount()
const done = render(<MarkdownText text={fence} />)
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',

View File

@@ -0,0 +1,31 @@
/* Syntax-highlight token palette: the values behind shiki's css-variables
theme (--shiki-* custom properties emitted by the ui-primitives CodeBlock).
Light values on :root, dark overrides on the body attribute — the same
cascade as every other token sheet. Background/foreground deliberately
alias the markdown code-block tokens so highlighted and plain blocks agree. */
:root {
--shiki-foreground: var(--dsw-alias-label-primary);
--shiki-background: var(--dsw-alias-markdown-code-block);
--shiki-token-constant: #1c7ed6;
--shiki-token-string: #2f9e44;
--shiki-token-comment: #868e96;
--shiki-token-keyword: #d6336c;
--shiki-token-parameter: #e8590c;
--shiki-token-function: #6741d9;
--shiki-token-string-expression: #2b8a3e;
--shiki-token-punctuation: #495057;
--shiki-token-link: #1971c2;
}
body[data-ds-dark-theme] {
--shiki-token-constant: #4dabf7;
--shiki-token-string: #69db7c;
--shiki-token-comment: #adb5bd;
--shiki-token-keyword: #faa2c1;
--shiki-token-parameter: #ffa94d;
--shiki-token-function: #b197fc;
--shiki-token-string-expression: #8ce99a;
--shiki-token-punctuation: #ced4da;
--shiki-token-link: #74c0fc;
}

View File

@@ -61,6 +61,17 @@
background: var(--dsw-alias-state-warn-tertiary);
}
/* run_code sub-dispatch cells: the business tint plus an indent so the
nesting under the parent Tool cell reads at a glance. */
.tagSubtool {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
}
.root[data-kind='subtool'] {
padding-left: 28px;
}
.text {
flex: 1 1 auto;
min-width: 0;

View File

@@ -4,20 +4,23 @@
import type { HTMLAttributes } from 'react'
import css from './TrajectoryCell.module.css'
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */
export type TrajectoryCellKind = 'user' | 'message' | 'tool'
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think;
* subtool = one run_code sub-dispatch nested under its Tool cell). */
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
/** Display label per kind (matches the design tags). */
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
user: 'User',
message: 'Message',
tool: 'Tool',
subtool: 'Sub',
}
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
user: css.tagUser!,
message: css.tagMessage!,
tool: css.tagTool!,
subtool: css.tagSubtool!,
}
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {

View File

@@ -12,9 +12,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = useSession((s) => s.nodes)
const partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls)
const codeDispatches = useSession((s) => s.codeDispatches)
const turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
[nodes, partial, runningCalls],
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
[nodes, partial, runningCalls, codeDispatches],
)
if (turns.length === 0) {
return <div className={css.root}><p className={css.empty}></p></div>

View File

@@ -1,16 +1,20 @@
// WaterfallView: P-I placeholder body for the waterfall tab — span stats
// header over node-count bars per turn standing in for duration lanes (no
// timing data yet; deviation ledger #3 defers real rendering to P-III).
// WaterfallView: span stats header over per-turn node-count lanes (P-I
// stand-in for duration lanes; deviation ledger #3). run_code turns
// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
// carries per-sub-call wall time, so each sub-span's width is its real
// duration against the parent turn's dispatch window.
import { useMemo } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import { deriveSpans, deriveSubSpans } from './spans.ts'
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
import css from './views.module.css'
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
const PX_PER_NODE = 14
const MIN_BAR_PX = 8
/** Sub-span lane width budget (the parent window scales into this). */
const SUB_LANE_PX = 220
/** Optional density override (test/standalone knob; the register site passes nothing). */
export interface WaterfallExtraProps {
@@ -21,27 +25,48 @@ export interface WaterfallExtraProps {
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession((s) => s.nodes)
const codeDispatches = useSession((s) => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}></p></div>
return (
<>
<TrajectoryStatsHeader useSession={useSession} />
<div className={css.root}>
{spans.map((span, i) => (
<div key={span.turn} className={css.row} style={{ paddingLeft: i * 12 }}>
<span className={css.turnTag}>turn {span.turn}</span>
<span
className={css.bar}
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
title={`${span.nodes} nodes`}
/>
{span.calls > 0 && (
<div key={span.turn}>
<div className={css.row} style={{ paddingLeft: i * 12 }}>
<span className={css.turnTag}>turn {span.turn}</span>
<span
className={`${css.bar} ${css.barCalls}`}
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
title={`${span.calls} tool calls`}
className={css.bar}
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
title={`${span.nodes} nodes`}
/>
)}
{span.calls > 0 && (
<span
className={`${css.bar} ${css.barCalls}`}
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
title={`${span.calls} tool calls`}
/>
)}
</div>
{(subSpans.get(span.turn) ?? []).map((lane) => (
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
<span className={css.subTag}>{lane.name}</span>
<span
className={`${css.bar} ${css.barSub}`}
data-timing={lane.timing}
style={{
marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
}}
title={lane.timing === 'measured'
/* durationMs is non-null exactly when timing is measured. */
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)}s`
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
/>
</div>
))}
</div>
))}
</div>

View File

@@ -4,6 +4,7 @@
*/
import type {
AssistantMessageNode,
CodeSubCall,
ConversationSnapshot,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -27,6 +28,8 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
interface UsageLike {
@@ -49,7 +52,7 @@ interface LaidCell {
* @returns turns ordered by first appearance.
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const { nodes, partial, runningCalls } = input
const { nodes, partial, runningCalls, codeDispatches } = input
const resultByCall = indexResults(nodes)
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
let index = 0
@@ -96,7 +99,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'assistant') {
const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall)
const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
for (const laid of laidList) {
if (node.step > 0) pushStep(node.turn, node.step, laid)
else pushMessage(node.turn, laid)
@@ -128,6 +131,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
timeSeconds: durationSeconds(node.time, node.callTime),
},
})
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
pushStep(0, 1, laid)
index = laid.cell.index
}
}
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
}
@@ -161,6 +168,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
timeSeconds: null,
},
})
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
index = laid.cell.index
}
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
@@ -387,6 +398,53 @@ function collectCallIds(
return ids
}
/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
if (codeDispatches.size === 0) return laidList
const out: LaidCell[] = []
let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
for (const laid of laidList) {
out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
if (laid.callId === undefined) continue
for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
out.push(sub)
index = sub.cell.index
}
}
return out
}
/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
function expandSubCalls(
subs: readonly CodeSubCall[] | undefined,
startIndex: number,
): LaidCell[] {
if (subs === undefined || subs.length === 0) return []
const out: LaidCell[] = []
let index = startIndex
for (const sub of subs) {
const settled = 'kind' in sub
out.push({
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
callId: sub.callId,
cell: {
index: ++index,
kind: 'subtool',
text: settled
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
: summarizeCall(sub.name, sub.argsRaw),
// PR3's start/settle pair carries per-sub-call wall time; a running
// (unsettled) or pre-pair log entry shows the em dash.
timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
},
})
}
return out
}
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
if (args === '') return name

View File

@@ -5,6 +5,24 @@
*/
import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */
export interface SubSpanLane {
callId: string
name: string
/** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */
durationMs: number | null
/**
* Timing provenance: `measured` = start/settle pair observed; `running` =
* start seen, settle pending; `unknown` = settle-only replay window (the
* start fell outside), so no duration claim is possible.
*/
timing: 'measured' | 'running' | 'unknown'
/** Start offset as a fraction of the parent turn's dispatch window [0, 1). */
offsetFraction: number
/** Width as a fraction of the window (running lanes extend to the window end). */
widthFraction: number
}
/** One turn's worth of activity, folded from the snapshot node window. */
export interface TurnSpan {
turn: number
@@ -69,3 +87,61 @@ export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
return node.kind === 'assistant' || node.kind === 'steering'
}
/**
* Fold the dispatch index into per-turn sub-span lanes with REAL timing: each
* lane's offset/width scale against its parent turn's dispatch window (first
* start → last settle). Running (unsettled) lanes extend to the window end
* with a null duration.
* @param nodes - snapshot nodes (locates each parent run_code call's turn).
* @param codeDispatches - the snapshot's dispatch index.
* @returns lanes keyed by turn, in start order.
*/
export function deriveSubSpans(
nodes: ConversationSnapshot['nodes'],
codeDispatches: ConversationSnapshot['codeDispatches'],
): ReadonlyMap<number, readonly SubSpanLane[]> {
const out = new Map<number, SubSpanLane[]>()
if (codeDispatches.size === 0) return out
const turnByCall = new Map<string, number>()
let currentTurn = 0
for (const node of nodes) {
if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn
if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn)
}
for (const [parent, subs] of codeDispatches) {
if (subs.length === 0) continue
const turn = turnByCall.get(parent) ?? currentTurn
// A settle-only entry (callTime null: its start fell outside the replay
// window) anchors the window by its settle time — a real observation —
// but must never masquerade as a measured zero-duration span.
const starts: number[] = []
const ends: number[] = []
for (const sub of subs) {
const settled = 'kind' in sub
const start = settled ? sub.callTime ?? sub.time : sub.time
starts.push(start)
ends.push(settled ? sub.time : start)
}
const windowStart = Math.min(...starts)
const windowEnd = Math.max(...ends, windowStart + 1)
const windowSpan = windowEnd - windowStart
const lanes: SubSpanLane[] = subs.map((sub, i) => {
const settled = 'kind' in sub
const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const
const start = starts[i] ?? windowStart
const end = settled ? sub.time : windowEnd
return {
callId: sub.callId,
name: settled ? sub.call?.name ?? sub.callId : sub.name,
durationMs: timing === 'measured' ? Math.max(0, end - start) : null,
timing,
offsetFraction: (start - windowStart) / windowSpan,
widthFraction: Math.max((end - start) / windowSpan, 0.02),
}
})
const existing = out.get(turn) ?? []
out.set(turn, [...existing, ...lanes])
}
return out
}

View File

@@ -45,3 +45,38 @@
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
offset/width scaled to the dispatch window (real wall time). A running
lane pulses via reduced opacity until its settle arrives. */
.subRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.subTag {
flex: none;
width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.barSub {
height: 8px;
background: var(--dsw-alias-state-business-primary);
}
.barSub[data-timing='running'] {
opacity: 0.45;
}
/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
.barSub[data-timing='unknown'] {
background: transparent;
border: 1px dashed var(--dsw-alias-state-business-primary);
}

View File

@@ -70,7 +70,7 @@ describe('deriveTrajectoryLayout', () => {
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
@@ -86,6 +86,7 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [] as unknown as ConversationSnapshot['nodes'],
partial: null,
runningCalls: [{
@@ -111,7 +112,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
@@ -137,7 +138,7 @@ describe('deriveTrajectoryLayout', () => {
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
})
@@ -154,7 +155,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'ok2' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns.map((t) => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
@@ -168,7 +169,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3,
@@ -196,7 +197,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap((g) => g.cells)
.find((c) => c.kind === 'message' && c.text === 'done')
@@ -204,3 +205,51 @@ describe('deriveTrajectoryLayout', () => {
expect(message?.timeSeconds).toBe(1)
})
})
describe('run_code sub-dispatch cells', () => {
const runCodeNodes = [
{
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
blocks: [
{ kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
],
},
{
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const settledSub = (n: number, name: string, start: number, end: number) => ({
kind: 'tool-result' as const, seq: 100 + n, time: end,
callId: `p1:code:${n}`,
call: { name, argsRaw: '{"x":1}' }, callTime: start,
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
})
it('nests settled sub-cells after their parent Tool cell with real durations', () => {
const codeDispatches = new Map([['p1', [
settledSub(1, 'bash', 6_300, 7_300),
settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap((g) => g.cells)
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
// Sequential indexes across the interleave; durations from the pair times.
expect(cells.map((c) => c.index)).toEqual([1, 2, 3])
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
})
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
turn: 0, step: 0, time: 6_400, callView: null,
}
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
})
})

View File

@@ -21,7 +21,7 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
@@ -54,7 +54,7 @@ const NODES = [
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -117,7 +117,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create()
@@ -260,3 +260,115 @@ describe('node half', () => {
expect(nodeApply()).toBeUndefined()
})
})
describe('deriveSubSpans (waterfall lanes)', () => {
const dispatchNodes = [
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
{
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
it('scales settled lanes into the dispatch window with real durations', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const turn3 = lanes.get(3)
expect(turn3).toHaveLength(2)
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
})
it('a running lane extends to the window end with a null duration', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
// Extends from its start to the window end.
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
})
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
})
it('waterfall renders sub-span lanes under the owning turn row', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const lane = view.container.querySelector('[data-subspan]')
expect(lane).not.toBeNull()
expect(lane!.textContent).toContain('bash')
expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
})
it('waterfall labels a settle-only lane as duration unknown', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const bar = view.container.querySelector('[data-timing="unknown"]')
expect(bar).not.toBeNull()
expect(bar!.getAttribute('title')).toContain('duration unknown')
})
})

View File

@@ -1,9 +1,10 @@
/* Shell-owned global base: full-height mount plus the theme token sheets.
* The three ui-theme sheets are the sole token source (--dsw-*); the shell
* The four ui-theme sheets are the sole token source (--dsw-*); the shell
* links them here so tokens exist before any plugin CSS lands. */
@import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';
html,
body,

View File

@@ -768,38 +768,38 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'tasks',
summary: 'The `tasks` service: the runtime-global background task registry.',
summary: 'Abstract background task registry.',
methods: [
{
signature: 'start(spec: TaskStart): TaskId',
signature: 'abstract start(spec: TaskStart): TaskId',
jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `<kind>-N` id.\n */',
},
{
signature: 'list(caller?: Agent): TaskSnapshot[]',
signature: 'abstract list(caller?: Agent): TaskSnapshot[]',
jsDoc: '/**\n * List caller-owned and unowned tasks in registration order without exposing\n * another session\'s labels.\n * @param caller - reading agent; a non-agent caller sees only unowned tasks.\n * @returns fresh snapshots.\n */',
},
{
signature: 'get(id: TaskId, caller?: Agent): TaskSnapshot',
signature: 'abstract get(id: TaskId, caller?: Agent): TaskSnapshot',
jsDoc: '/**\n * Return a non-consuming snapshot without changing its read cursor or notice\n * state. Throws for an unknown or foreign task.\n * @param id - task to look up.\n * @param caller - reading agent checked against the owner.\n * @returns a fresh snapshot.\n */',
},
{
signature: 'read(id: TaskId, caller?: Agent): TaskRead',
signature: 'abstract read(id: TaskId, caller?: Agent): TaskRead',
jsDoc: '/**\n * Read the next stream delta, or the idempotent final output after settlement.\n * A terminal read marks the task reported. Throws for an unknown or foreign\n * task.\n * @param id - task to read.\n * @param caller - reading agent checked against the owner.\n * @returns output text and the post-read snapshot.\n */',
},
{
signature: 'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
signature: 'abstract kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
jsDoc: '/**\n * Request cancellation, then mark the task stopping and reported. A producer\n * throw propagates without changing task state. Throws for an unknown or\n * foreign task.\n * @param id - task to cancel.\n * @param caller - killing agent checked against the owner.\n * @param reason - logged reason forwarded to the producer.\n * @returns `requested` for live work, otherwise `already-finished`.\n */',
},
{
signature: 'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>',
jsDoc: '/**\n * Wait for settlement or timeout without cancelling the task. Caller abort\n * rejects only while the task is live; after settlement it returns the\n * terminal snapshot so a notice suppressed for this waiter is still delivered.\n * Timed-out and aborted waits detach their resolvers. Throws for invalid,\n * unknown, or foreign input.\n * @param id - task to wait for.\n * @param timeoutMs - positive finite wait bound in milliseconds.\n * @param caller - waiting agent checked against the owner.\n * @param signal - optional cancellation of the wait itself.\n * @returns snapshot at settlement or timeout.\n */',
signature: 'abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>',
jsDoc: '/**\n * Wait for settlement or timeout without cancelling the task. Caller abort\n * rejects only while the task is live; after settlement the terminal\n * snapshot wins so a notice suppressed for this waiter is still delivered.\n * Throws for invalid, unknown, or foreign input.\n * @param id - task to wait for.\n * @param timeoutMs - positive finite wait bound in milliseconds.\n * @param caller - waiting agent checked against the owner.\n * @param signal - optional cancellation of the wait itself.\n * @returns snapshot at settlement or timeout.\n */',
},
{
signature: 'onTaskDone(listener: TaskDoneListener): () => void',
signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void',
jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */',
},
{
signature: 'attachSurface(name: string): () => void',
signature: 'abstract attachSurface(name: string): () => void',
jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */',
},
],
@@ -1218,6 +1218,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * A tool was registered or unregistered, or a scoped restriction changed\n * (the available tool set changed — possibly for one scope only). An\n * UNFILTERED registry-subject notification, deliberately not scope-filtered\n * dispatch: a global change concerns every agent\'s next assembly, so a\n * scoped listener subscribing here sees every change, not just its own\n * scope\'s.\n * @mode emit\n */',
summary: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only).',
},
{
name: 'tools/code-dispatch-log',
mode: 'waterfall',
signature: '\'tools/code-dispatch-log\'(this: Scoped<ToolRegistry>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>',
jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */',
summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.',
},
{
name: 'tools/execute',
mode: 'waterfall',

View File

@@ -35,6 +35,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'subagent/end': null,
'subagent/start': null,
'system-prompt/assemble': args => (args[1] as Record<string, unknown>)['scope'],
'tools/code-dispatch-log': args => (args[0] as Record<string, unknown>)['agent'],
'tools/execute': args => (args[0] as Record<string, unknown>)['agent'],
'tools/post-execute': args => (args[0] as Record<string, unknown>)['agent'],
'tools/pre-execute': args => (args[0] as Record<string, unknown>)['agent'],

View File

@@ -63,6 +63,7 @@ describe('scoped-dispatch invariants', () => {
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]],
['system-prompt/assemble', [[], { scope: agent }]],
['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],

View File

@@ -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
README.md: 18d6d385ff0c35ddbe7dc9a172ce9cd563bc4c1c
README.zh.md: 93ea574eb01fd27fcd68f8b58a9e4187dfbd4fcb
README.md: e46ff43c95df0ae1a6ec536d30417b342c11b151
README.zh.md: abe4dbef6c7d26861cab987704c772a45e57a808

View File

@@ -52,7 +52,7 @@ Durable values need one accepted representation, not a check followed by a secon
### Chunk-row storage codec (`chunk-rows.ts`)
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the write-side switch is the backend's `packChunks` config.
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the backend's default-enabled `packChunks` config controls writes only.
### Surface types

View File

@@ -52,7 +52,7 @@
### 分片行存储编解码器(`chunk-rows.ts`
提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks``reasoning-chunks``tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0``time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq``time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture测试前置数据读取器`dsh-llm-replay``dsh-acp-snapshot`)共享同一编解码器;写入侧开关是后端的 `packChunks` 配置。
提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks``reasoning-chunks``tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0``time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq``time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture测试前置数据读取器`dsh-llm-replay``dsh-acp-snapshot`)共享同一编解码器;后端默认启用`packChunks` 配置只控制写入
### Surface 类型

View File

@@ -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
README.md: 14b7896e413a56fcee5a7db4cd92813f3e91c286
README.zh.md: c89b03ff12bd346a2c0a8848a0e61b8fd738c318
README.md: 3a9082f99663586936ce1eafaf09264a91798e28
README.zh.md: 763ae1195d2c7a2606806970a3ba6371114d9d1c

View File

@@ -114,16 +114,16 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
### Code Mode
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>` and a bounded Native-content summary; `deriveMessages()` does not surface that event or persist the value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
### Parallel execution
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings reuse the same classification through the bridge's own pool. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
## Model Experience
@@ -156,7 +156,7 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
- Calls execute sequentially, even under `Promise.all`.
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
The available tools:
@@ -191,5 +191,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
- **Code Mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap.
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).

View File

@@ -114,16 +114,16 @@ ctx.tools.register(defineTool({
### Code Mode
`code``both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap``ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会按顺序重新进入完整工具流水线,并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 进行 reject且只携带 `toolName``message`Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并 drain 尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
`code``both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap``ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 进行 reject且只携带 `toolName``message`Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并 drain 尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
- **SDK 段**`tools:sdk`,顺序 150一个惰性提示词段每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap``ToolName``ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache。导出的代码生成器 `jsonSchemaToTs` 会处理统一 schema 的每种构造,并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出。
- **分发桥接层**`run_code` 的 execute每个绑定调用都会在分发前快照为无损 JSON`undefined``BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),通过每次运行独有的队列串行化(即使使用 `Promise.all`,底层调用也会按提交顺序逐个执行),以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker并成为 `ToolCallError(toolName, message)`。每个子调用都会记录为 `tool/code-dispatch` 会话事件,其确定性 id `<parent>:code:<n>`并附带有界的 Native 内容摘要;`deriveMessages()` 不会公开该事件或持久化值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
- **分发桥接层**`run_code` 的 execute每个绑定调用都会在分发前快照为无损 JSON`undefined``BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发契约的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
- **结算纪律**:桥接层拥有一次运行作用域的中止;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前 drain 队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError``code: 'CODE_RUN_FAILED'`message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`
- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB只应用于组合序列化后的外层日志数组、完成值或失败消息载荷固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。
### 并行执行
agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `exclusive` 调用视为顺序屏障。只有分发主体会重叠策略、持久结果和上下文仍保持模型顺序。Code Mode 绑定仍按串行执行。[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) 规定已交付声明及其原理。
agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `exclusive` 调用视为顺序屏障。只有分发主体会重叠策略、持久结果和上下文仍保持模型顺序。Code Mode 绑定通过桥接层自己的池复用同一套分类。[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) 规定已交付声明及其原理。
## 模型体验
@@ -156,7 +156,7 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
- Calls execute sequentially, even under `Promise.all`.
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
The available tools:
@@ -191,5 +191,5 @@ The available tools:
- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。
- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。
- **Code Mode 只支持 TypeScript且呈现模式在服务内统一**`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native而另一个仅使用 Code。
- **Code Mode 中间值只存在于执行局部,且没有字节上限**:无法从会话回放重建这些值,它们可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。
- **Code Mode 中间值只存在于执行局部,且没有字节上限**这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则**有**上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
- **每次运行都会获得全新的 `run_code` 状态**MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。

View File

@@ -1,37 +1,52 @@
/**
* Code Mode `run_code` transport. Programs call the registry's agent-visible
* tools through nested, sequential executions; each sub-dispatch is logged for
* reconstruction, while only the outer curated result enters model history.
* tools through nested executions scheduled under the native concurrency
* contract; each sub-dispatch is logged for reconstruction, while only the
* outer curated result enters model history.
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
import { parse } from 'node:path'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import type { ToolDefinition, ToolRegistry } from './index.ts'
import { TOOL_REGISTRY_SCHEDULER } from './index.ts'
import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One bridged sub-dispatch from a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
* so this append can never fail on payload shape — whether the sub-call
* errored, and a bounded `resultSummary` of its model-facing text. Before
* bounding, occurrences of a non-root session workspace path are
* normalized to `.` so host-specific absolute path lengths cannot change
* the summary.
* One sub-dispatch STARTING inside a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`,
* numbered in submission order), and the tool `name` with its
* JSON-normalized `arguments` — the exact value dispatched, normalized
* BEFORE dispatch, so this append can never fail on payload shape.
* Appended when the scheduler actually starts the call (not at
* submission), so a start means the tool body pipeline was entered; a
* call abandoned in the queue logs nothing. Log-only: `deriveMessages()`
* ignores it; UIs use it for live per-sub-call running state and pair it
* with `tool/code-dispatch` by `subCallId` (timing = the two events'
* `time` fields).
*/
'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown }
/**
* One bridged sub-dispatch SETTLING: the pairing ids (matching the
* `tool/code-dispatch-start` with the same `subCallId`), the tool `name`
* with the same JSON-normalized `arguments`, and the sub-call's complete
* model-facing outcome in `tool/result`'s own vocabulary
* (`content` + `isError`), so UIs render a sub-call through the exact
* code path that renders a native call. Every started sub-call settles
* with exactly one of these (abort included: the aborted pipeline result
* is an `isError` outcome).
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains its queue before
* returning), so the turn-enclosure invariant holds by construction.
* parent `run_code`'s execution (the bridge drains in-flight dispatches
* before returning), so the turn-enclosure invariant holds by
* construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }
}
}
@@ -55,35 +70,6 @@ export class CodeRunFailedError extends HarnessError {
}
}
/**
* Cap for a `tool/code-dispatch` event's `resultSummary`. A log-ergonomics
* constant, not config: the full result already flows to the program; the
* summary exists so log readers see what a sub-call returned at a glance.
*/
const SUMMARY_MAX_CHARS = 200
/** Join Native content for the bounded durable sub-dispatch summary; non-text blocks become diagnostic placeholders. */
function textOf(content: ContentBlock[]): string {
return content
.map((block) => {
switch (block.type) {
case 'text': return block.text
// ContentBlockMap is merge-extensible — future block kinds land here
// deliberately (no assertNever on merge-extensible unions).
default: return `[${block.type} content]`
}
})
.join('\n')
}
/** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */
function summarize(text: string, cwd: string | undefined): string {
const stableText = cwd === undefined || cwd === parse(cwd).root
? text
: text.replaceAll(cwd, '.')
return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}` : stableText
}
/**
* Snapshot one binding call's argument as lossless JSON, then snapshot that
* detached value again so dispatch and logging stay independent without
@@ -201,17 +187,32 @@ function renderValue(value: JsonValue): string {
type RunCodeOutput = { logs: string[]; result?: JsonValue }
/**
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
* executed through the dispatch bridge described above. The
* Registry-private capabilities the bridge receives at construction — the
* `requireRuntime` idiom: operations only the owning registry can mint stay
* off its public service surface and flow here as closures instead.
*/
export interface RunCodeBridgeOptions {
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
requireRuntime: () => CodeRuntime
/** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */
maxParallel: number
/** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */
shapeDispatchLog: (dispatch: CodeDispatchLog) => Promise<ContentBlock[]>
}
/**
* Build the `run_code` {@link ToolDefinition}: required `code` and
* `description` parameters, executed through the dispatch bridge described
* above. The
* registry reserves it as presentation infrastructure under non-native modes,
* outside the filterable global/scoped capability layers.
* @param registry - the owning registry (sub-calls go through its `execute`,
* bindings cover its registered tools).
* @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
* misconfiguration error (shared with the registry's assembly-time checks).
* @param options - the registry-private capabilities described above.
* @returns the registry-ready definition.
*/
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition {
const { requireRuntime, maxParallel, shapeDispatchLog } = options
return defineTool({
name: RUN_CODE_NAME,
description:
@@ -221,6 +222,13 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
+ 'Only what you print or return comes back — curate it.',
parameters: {
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
description: {
type: 'string',
required: true,
description: 'Clear, concise description of what this program does in active voice, '
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".',
},
},
output: {
schema: {
@@ -238,6 +246,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
},
},
async execute(args, exec): Promise<RunCodeOutput> {
if (args.description.trim().length === 0) {
throw new Error('invalid description: expected a non-empty string')
}
const runtime = requireRuntime()
// The run-scoped abort: follows the outer signal in, and fires when the
@@ -249,19 +260,120 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.signal.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the tail, so even
// `Promise.all` executes the underlying tool calls one at a time in submission order (the
// tool contract carries no concurrency-safety metadata yet).
let queue: Promise<void> = Promise.resolve()
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
const turn = queue.then(() => {
if (runController.signal.aborted) {
throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`)
// The per-run scheduler, reusing the NATIVE concurrency contract through
// the registry's staged view (the loop scheduler's own seam) — and the
// native loop's SEQUENCING: every ordered stage (the dispatch-start
// append, prepare = pre-execute/guards, finalize/finish = post-execute,
// context deferral, the settle append) runs inside ONE driver lane, so
// ordered policy stages never overlap each other and only the
// around-dispatch/body stage runs concurrently. Starts are strictly
// submission-ordered; results commit in submission order through the
// head-of-line cursor. Consecutive parallel-classified calls overlap up
// to maxParallel; an exclusive call waits for the pool to drain, runs
// alone, and holds its barrier until its COMMIT (post-execute included)
// completes, exactly like a native exclusive group. Classification is
// re-read via executionMode() immediately before each start (a registry
// mutation while queued can flip a call exclusive), matching the native
// scheduler's lazy reclassification.
interface PendingDispatch {
/** Ordered stage: append the start event, await prepare (pre-execute/guards), launch the body into `flight`. */
start(): Promise<void>
classify(): 'parallel' | 'exclusive'
abandon(): void
/** Ordered stage: post-execute + context deferral + settle event, in submission order. */
commit(): Promise<void>
/** The launched around-dispatch/body stage; resolved until start() replaces it. */
flight: Promise<void>
/** True once the dispatch stage parked its outcome; the commit cursor waits on it. */
settled: boolean
/** The classification this entry started under; an exclusive holds its barrier through commit(). */
mode?: 'parallel' | 'exclusive'
}
const pendingQueue: PendingDispatch[] = []
const inFlight = new Set<Promise<void>>()
/** Tracked settle-event side work (log shaping + append), drained at run settlement. */
const logWork = new Set<Promise<void>>()
const commitQueue: PendingDispatch[] = []
let exclusiveActive = false
let driving = false
let driverRun: Promise<void> = Promise.resolve()
let wake: (() => void) | undefined
const wakeup = (): void => {
const release = wake
wake = undefined
release?.()
}
/**
* The single ordered lane. Each pass commits the head-of-line settled
* dispatch (ordered post-execute), then starts the next queued entry if
* its slot is free (ordered pre-execute), and otherwise sleeps until a
* body settles or a new submission arrives. One run reaching the
* empty-queues/empty-pool state is quiescence.
*/
const drive = (): Promise<void> => {
if (driving) return driverRun
driving = true
driverRun = (async () => {
try {
for (;;) {
// Arm before inspecting state so a settle or submission landing
// between the checks and the await below cannot be lost.
const signal = new Promise<void>((resolve) => { wake = resolve })
const commitHead = commitQueue[0]
if (commitHead !== undefined && commitHead.settled) {
commitQueue.shift()
await commitHead.commit()
// The barrier covers post-execute: later starts wait for the
// exclusive call's full pipeline, as under the native loop.
if (commitHead.mode === 'exclusive') exclusiveActive = false
continue
}
const head = pendingQueue[0]
if (head !== undefined) {
if (runController.signal.aborted) {
pendingQueue.shift()
head.abandon()
continue
}
// Reclassify at start time (fail-closed on registry changes).
const mode = head.classify()
const capacity = !exclusiveActive
&& (mode === 'exclusive' ? inFlight.size === 0 : inFlight.size < maxParallel)
if (capacity) {
if (mode === 'exclusive') exclusiveActive = true
head.mode = mode
pendingQueue.shift()
// Joined before start() so the commit cursor sees submission
// order; nothing commits it until `settled` flips.
commitQueue.push(head)
await head.start()
const flight: Promise<void> = head.flight.finally(() => {
inFlight.delete(flight)
wakeup()
})
inFlight.add(flight)
continue
}
}
if (pendingQueue.length === 0 && commitQueue.length === 0 && inFlight.size === 0) return
await signal
}
} finally {
driving = false
wake = undefined
}
return task()
})
queue = turn.then(() => undefined, () => undefined)
return turn
})()
return driverRun
}
/** Every dispatch settled AND committed; nothing can start (the run is aborted at call time). */
const drainDispatches = async (): Promise<void> => {
// The abort already fired: the driver abandons queued-unstarted
// entries, awaits the live pool, and drains the ordered commit lane —
// including a commit already in progress when the program returned.
await drive()
// Every settle's shaped append lands inside the open run_code turn
// (tasks self-remove on settlement).
while (logWork.size > 0) await Promise.allSettled([...logWork])
}
// Read through a call, not a bare property: the abort state genuinely
@@ -274,35 +386,110 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
}
const normalized = jsonNormalizeArgs(rawArgs)
const outcome = await enqueue(async () => {
const n = ++dispatches
const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
const result = await registry.execute({
callId: subCallId,
name,
arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {},
parent: exec.token,
signal: runController.signal,
})
for (const context of result.additionalContexts ?? []) {
exec.deferContext(context)
const n = ++dispatches
const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
const input = {
callId: subCallId,
name,
arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {},
parent: exec.token,
signal: runController.signal,
}
type DispatchOutcome = { isError: true; message: string } | { isError: false; value: JsonValue }
const scheduler = registry[TOOL_REGISTRY_SCHEDULER]
const outcome = await new Promise<DispatchOutcome>((resolve, reject) => {
// Set by the dispatch stage (or start() for a pre-settled result): what commit() finalizes in submission order.
let parked:
| { kind: 'post-result' | 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
| undefined
const settle = (result: ToolExecutionResult): void => {
// The program gets its value NOW: log shaping (e.g. a spill
// backend) must never delay the binding or occupy a dispatch
// slot. The shaped append is tracked side work; the run's
// settlement drains logWork so every settle event still lands
// inside the open turn (shapeDispatchLog is contained, so this
// chain cannot reject).
resolve(result.isError
? { isError: true, message: result.error.message }
: { isError: false, value: result.value })
const agent = exec.agent
if (agent === undefined) return
const task: Promise<void> = (async () => {
// The durable copy may be reshaped (e.g. spilled to a preview +
// locator) by the log-shaping waterfall; the program's value
// and the model contract are untouched.
const logged = await shapeDispatchLog({
exec, agent, subCallId, name, isError: result.isError,
// The registry deep-froze this projection at result
// finalization; append snapshots the final copy again, so
// the log stays detached.
content: result.content,
})
agent.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,
name,
// The SIBLING parse of the dispatched value: byte-identical JSON,
// but a separate object — a tool mutating its args cannot desync
// this record from what it actually received.
arguments: normalized.logged,
isError: result.isError,
content: logged,
})
})().finally(() => { logWork.delete(task) })
logWork.add(task)
}
const text = textOf(result.content)
exec.agent?.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,
name,
// The SIBLING parse of the dispatched value: byte-identical JSON,
// but a separate object — a tool mutating its args cannot desync
// this record from what it actually received.
arguments: normalized.logged,
isError: result.isError,
resultSummary: summarize(text, exec.agent.session.header.cwd),
pendingQueue.push({
flight: Promise.resolve(),
settled: false,
// Re-read per driver pass against the same agent view the SDK
// declared; fail-closed exclusive when undeclared/invalid.
classify: () => registry.executionMode(input).kind,
abandon: () => {
reject(new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} tool call abandoned`))
},
async start(): Promise<void> {
exec.agent?.session.append('tool/code-dispatch-start', {
parentCallId: exec.callId,
subCallId,
name,
arguments: normalized.logged,
})
// Ordered prepare runs INSIDE the driver lane: the next entry's
// pre-execute waits for this resolution, as under the native
// scheduler. Only the launched body below overlaps.
const prepared = await scheduler.prepare(input)
if (prepared.kind === 'dispatch') {
this.flight = scheduler.dispatch(prepared.exec).then((dispatchOutcome) => {
parked = { kind: dispatchOutcome.kind, exec: prepared.exec, result: dispatchOutcome.result }
this.settled = true
})
return
}
parked = { kind: prepared.kind, exec: prepared.exec, result: prepared.result }
this.settled = true
},
async commit(): Promise<void> {
/* v8 ignore next -- commit() runs only after `settled` flipped, which set parked. */
if (parked === undefined) return
const result = parked.kind === 'post-result'
? await scheduler.finalize(parked.exec, parked.result)
: scheduler.finish(parked.exec, parked.result)
for (const context of result.additionalContexts ?? []) {
exec.deferContext(context)
}
settle(result)
// Backpressure on the shaped-append side channel: pending log
// tasks (each retaining a full result while a slow backend
// stores it) are bounded by the pool cap — beyond it the
// ordered lane waits, so later sub-calls cannot start and
// pending I/O/memory cannot grow without bound.
while (logWork.size > maxParallel) await Promise.race(logWork)
},
})
return result.isError
? { isError: true as const, message: result.error.message }
: { isError: false as const, value: result.value }
wakeup()
void drive()
})
// A budget expiry or outer cancel that lands while this call was in
// flight already aborted the dispatch; stop the program now rather
@@ -345,10 +532,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
signal: runController.signal,
})
} finally {
// Abort sub-dispatches and drain the folded queue before closing the turn.
// Abort sub-dispatches and drain every in-flight dispatch before
// closing the turn (queued-unstarted ones are abandoned unlogged).
// Binding failures remain observable through their individual promises.
runController.abort('run_code settled')
await queue
await drainDispatches()
}
if (result.error) {
@@ -363,10 +551,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.signal.removeEventListener('abort', onOuterAbort)
}
},
// The program is the call's always-visible UI label.
// The model-authored description is the call's always-visible UI label
// (the bash `description` precedent); the program itself rides rawInput.
presentCall: args => ({
card: 'generic',
title: args.code,
title: args.description,
kind: 'execute',
rawInput: args.code,
}),

View File

@@ -123,6 +123,19 @@ declare module 'cordis' {
* @mode waterfall
*/
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before
* the bridge appends its `tool/code-dispatch` event. `next()` keeps the
* content unchanged; a listener may return replacement blocks (e.g. the
* spill policy's preview + locator for an oversized text result). Only the
* logged copy is affected — the program already received the complete
* value, and the model sees neither. A throwing listener is contained:
* the bridge falls back to logging the unshaped content.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
* @param dispatch - the parent execution, sub-call identity, and the settled content to log.
* @mode waterfall
*/
'tools/code-dispatch-log'(this: Scoped<ToolRegistry>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
/**
* Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
@@ -272,6 +285,30 @@ export type ToolExecutionMode =
| { kind: 'parallel' }
| { kind: 'exclusive' }
/**
* One settled `run_code` sub-dispatch about to be logged, as seen by the
* `tools/code-dispatch-log` waterfall: the parent execution (session owner,
* outer call identity), the sub-call identity, and the outcome whose durable
* copy a listener may reshape. `content` is the RENDERED result projection
* (what a native `tool/result` would carry) — the program itself received
* the structured `value` (or just the error message on failure); only the
* `tool/code-dispatch` event's copy changes.
*/
export interface CodeDispatchLog {
/** The outer `run_code` execution. */
readonly exec: ToolExecution
/** The calling agent (the scope routing key and the spill owner), when the outer call has one. */
readonly agent?: Agent
/** Deterministic sub-call id (`<parent>:code:<n>`). */
readonly subCallId: CallId
/** The dispatched sub-tool name. */
readonly name: string
/** Whether the sub-call settled as an error. */
readonly isError: boolean
/** The sub-call's complete model-facing content (the settle event's default payload). */
readonly content: ContentBlock[]
}
/**
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
@@ -534,6 +571,14 @@ export interface Config {
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
/**
* Concurrency cap for a `run_code` program's overlapping sub-calls
* (default 10, the loop scheduler's own default). Sub-calls follow the
* native scheduling contract — only calls whose tools classify
* concurrency-safe overlap; exclusive calls form barriers — so `1`
* restores strictly serial dispatch. Must be a positive integer.
*/
maxParallelSubCalls?: number
}
/**
@@ -627,6 +672,15 @@ interface FusedToolSignal {
dispose(): void
}
/** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */
function resolveMaxParallelSubCalls(value: number | undefined): number {
const maxParallelSubCalls = value ?? 10
if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) {
throw new Error('maxParallelSubCalls must be a positive integer')
}
return maxParallelSubCalls
}
/**
* Tool registry and execution pipeline. Scoped registrations shadow globals;
* one visibility resolver feeds presentation, lookup, and dispatch.
@@ -636,6 +690,7 @@ export class ToolRegistry extends Service {
static Config: z<Config> = z.object({
mode: z.union(['native', 'code', 'both'] as const).default('native'),
maxParallelSubCalls: z.natural().min(1).default(10),
})
/** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
@@ -672,7 +727,11 @@ export class ToolRegistry extends Service {
// the filterable global/scoped capability layers.
this.codeTransport = this.mode === 'native'
? undefined
: createRunCodeTool(this, () => this.requireCodeRuntime())
: createRunCodeTool(this, {
requireRuntime: () => this.requireCodeRuntime(),
maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
})
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
if (this.mode !== 'native') {
ctx.systemPrompt.section({
@@ -923,6 +982,27 @@ export class ToolRegistry extends Service {
}
}
/**
* Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch
* and return the content the bridge should log on `tool/code-dispatch`.
* Contained: a throwing listener falls back to the unshaped content — log
* shaping must never fail the dispatch or lose the settle event. Private:
* the ONE consumer is the `run_code` bridge this registry constructs, which
* receives it as a capability parameter (the `requireRuntime` idiom) — the
* waterfall, not this invoker, is the public extension seam.
*/
private async shapeDispatchLog(dispatch: CodeDispatchLog): Promise<ContentBlock[]> {
try {
return await this.ctx.waterfall(
scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch,
() => Promise.resolve(dispatch.content),
)
} catch (error: unknown) {
this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`)
return dispatch.content
}
}
/**
* Execute through pre-policy, guards, around-dispatch, post-policy,
* definition-owned content finalization, and final notification. Tool and

View File

@@ -253,7 +253,7 @@ Pass \`run_code\` the body of an async TypeScript function (erasable syntax only
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
- A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue.
- Calls execute sequentially, even under \`Promise.all\`.
- Independent read-only calls MAY overlap under \`Promise.all\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
The available tools:`

View File

@@ -42,6 +42,7 @@ class FakeRuntime extends CodeRuntime {
interface SetupOptions {
mode?: Config['mode']
maxParallelSubCalls?: number
runtime?: false | { language?: string }
toolOrder?: string[]
}
@@ -49,7 +50,7 @@ interface SetupOptions {
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} })
await ctx.plugin(ToolRegistry, { mode: options.mode ?? 'code' })
await ctx.plugin(ToolRegistry, { mode: options.mode ?? 'code', ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} })
let runtime: FakeRuntime | undefined
if (options.runtime !== false) {
await ctx.plugin(FakeRuntime, options.runtime ?? {})
@@ -87,11 +88,11 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] {
}
/** A structural fake of the owning agent: captures session appends. */
function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent: Agent; events: { type: string; data: unknown }[] } {
function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } {
const events: { type: string; data: unknown }[] = []
const agent = {
session: {
header: options.cwd === undefined ? {} : { cwd: options.cwd },
header: { cwd: '/workspace' },
append: (type: string, data: unknown) => { events.push({ type, data }) },
},
} as unknown as Agent
@@ -99,12 +100,16 @@ function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent:
}
/** Dispatch run_code through the registry pipeline, as the loop would. */
async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise<ToolExecutionResult> {
async function runCode(
ctx: Context,
code: string,
extras: { agent?: Agent; signal?: AbortSignal; description?: string } = {},
): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId('call-1'),
name: RUN_CODE_NAME,
arguments: { code },
arguments: { code, description: extras.description ?? 'Run the test program' },
...extras.agent ? { agent: extras.agent } : {},
...extras.signal ? { signal: extras.signal } : {},
})
@@ -354,6 +359,331 @@ describe('mode-aware wire contribution', () => {
})
})
describe('the sub-dispatch scheduler (native concurrency contract)', () => {
/** Register a tool whose calls resolve only when the test releases them; returns live-call telemetry. */
function registerGated(ctx: Context, name: string, concurrencySafe: boolean) {
const gates: (() => void)[] = []
let live = 0
let peak = 0
const order: string[] = []
ctx.tools.register(defineTool({
name,
description: `Gated tool ${name}.`,
parameters: { id: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
...concurrencySafe ? { isConcurrencySafe: () => true } : {},
async execute(args, exec) {
order.push(`start:${args.id}`)
live++
peak = Math.max(peak, live)
// Abort-observing like a real tool: the run-scoped abort releases the
// gate so the bridge's drain reaches quiescence.
await new Promise<void>((release) => {
gates.push(release)
exec.signal.addEventListener('abort', () => { release() }, { once: true })
})
live--
order.push(`end:${args.id}`)
return `${name}:${args.id}`
},
}))
const release = (): void => { gates.shift()?.() }
const releaseAll = (): void => { while (gates.length > 0) gates.shift()!() }
return { order, release, releaseAll, peakLive: () => peak, pending: () => gates.length }
}
it('overlaps concurrency-safe calls under Promise.all and logs a start event per dispatch', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const gated = registerGated(ctx, 'safe_read', true)
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const all = Promise.all([
tools.safe_read!({ id: 'a' }),
tools.safe_read!({ id: 'b' }),
tools.safe_read!({ id: 'c' }),
])
// All three must be START-able without any completion (overlap proof).
await expect.poll(() => gated.pending()).toBe(3)
gated.releaseAll()
return { logs: [], value: (await all).map(String).join(',') }
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
expect(gated.peakLive()).toBe(3)
if (result.isError) throw new Error('expected success')
expect(result.value).toMatchObject({ result: 'safe_read:a,safe_read:b,safe_read:c' })
// One start per dispatch, paired with its settle by subCallId, starts in submission order.
const starts = events.filter(event => event.type === 'tool/code-dispatch-start').map(event => event.data as { subCallId: string })
const settles = events.filter(event => event.type === 'tool/code-dispatch').map(event => event.data as { subCallId: string })
expect(starts.map(start => start.subCallId)).toEqual(['call-1:code:1', 'call-1:code:2', 'call-1:code:3'])
expect(new Set(settles.map(settle => settle.subCallId))).toEqual(new Set(starts.map(start => start.subCallId)))
})
it('an exclusive call bars overlap: safe calls drain first, it runs alone, later calls wait', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const safe = registerGated(ctx, 'safe_read', true)
const unsafe = registerGated(ctx, 'writer', false)
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const reads = [tools.safe_read!({ id: 'r1' }), tools.safe_read!({ id: 'r2' })]
const write = tools.writer!({ id: 'w' })
const tail = tools.safe_read!({ id: 'r3' })
await expect.poll(() => safe.pending()).toBe(2)
// The exclusive call must NOT have started while the pool is live.
expect(unsafe.pending()).toBe(0)
safe.releaseAll()
await expect.poll(() => unsafe.pending()).toBe(1)
// The trailing safe call must NOT start while the exclusive one runs.
expect(safe.pending()).toBe(0)
unsafe.release()
await expect.poll(() => safe.pending()).toBe(1)
safe.releaseAll()
await Promise.all([...reads, write, tail])
return { logs: [], value: 'ordered' }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(safe.order.slice(0, 2)).toEqual(['start:r1', 'start:r2'])
expect(unsafe.order).toEqual(['start:w', 'end:w'])
// r3 started only after w ended.
expect(safe.order.indexOf('start:r3')).toBeGreaterThan(safe.order.indexOf('end:r1'))
})
it('maxParallelSubCalls caps the overlap window', async () => {
const { ctx, runtime } = await setup({ mode: 'code', maxParallelSubCalls: 2 })
const gated = registerGated(ctx, 'safe_read', true)
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const all = Promise.all([
tools.safe_read!({ id: 'a' }),
tools.safe_read!({ id: 'b' }),
tools.safe_read!({ id: 'c' }),
])
await expect.poll(() => gated.pending()).toBe(2)
// The third call waits for a slot.
expect(gated.pending()).toBe(2)
gated.release()
await expect.poll(() => gated.pending()).toBe(2)
gated.releaseAll()
await all
return { logs: [], value: 'capped' }
}
const result = await runCode(ctx, 'program')
if (result.isError) console.error('CAP-FAIL:', (result.content[0] as { text: string }).text)
expect(result.isError).toBe(false)
expect(gated.peakLive()).toBe(2)
})
it('a tool unregistered between binding enumeration and dispatch fails as unknown tool', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls: unknown[] = []
const dispose = ctx.tools.register(defineTool({
name: 'ephemeral',
description: 'Unregistered between binding enumeration and dispatch.',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
execute() {
calls.push('ran')
return Promise.resolve('ok')
},
}))
runtime.behavior = async (request) => {
// The binding exists (enumerated at run start); the registry mutation
// makes prepare resolve UNKNOWN_TOOL as a final-result, which commits
// through scheduler.finish (no post-execute).
dispose()
const message = await request.bindings[0]!.functions.ephemeral!({})
.then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error))
return { logs: [], value: message }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected success')
expect(result.value).toMatchObject({ result: 'unknown tool "ephemeral"' })
expect(calls).toEqual([])
})
it('ordered pre-execute never overlaps: a slow policy on one call delays the next start', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const gated = registerGated(ctx, 'safe_read', true)
const stages: string[] = []
let releaseGate: (() => void) | undefined
ctx.on('tools/pre-execute', async (preExec, next) => {
if (preExec.name !== 'safe_read') return next()
stages.push(`pre-enter:${String(preExec.callId)}`)
if (releaseGate === undefined) {
// The FIRST call's policy awaits an asynchronous decision.
await new Promise<void>((resolve) => { releaseGate = resolve })
}
stages.push(`pre-exit:${String(preExec.callId)}`)
return next()
})
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const all = Promise.all([tools.safe_read!({ id: 'a' }), tools.safe_read!({ id: 'b' })])
// Both submissions are in; the second pre-execute must NOT have entered
// while the first is still awaiting its policy decision.
await expect.poll(() => stages.length).toBeGreaterThanOrEqual(1)
expect(stages).toEqual(['pre-enter:call-1:code:1'])
releaseGate!()
await expect.poll(() => gated.pending()).toBe(2)
gated.releaseAll()
await all
return { logs: [], value: 'ordered-prepare' }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(stages).toEqual([
'pre-enter:call-1:code:1', 'pre-exit:call-1:code:1',
'pre-enter:call-1:code:2', 'pre-exit:call-1:code:2',
])
})
it('an exclusive call holds its barrier through post-execute: the next start waits for the commit', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const writer = registerGated(ctx, 'writer', false)
const reader = registerGated(ctx, 'safe_read', true)
const stages: string[] = []
let releasePost: (() => void) | undefined
ctx.on('tools/post-execute', async (postExec, _result, next): Promise<PostToolDecision> => {
if (postExec.name === 'writer') {
stages.push('post-enter:writer')
await new Promise<void>((resolve) => { releasePost = resolve })
stages.push('post-exit:writer')
}
return next()
})
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const w = tools.writer!({ id: 'w' })
const r = tools.safe_read!({ id: 'r' })
await expect.poll(() => writer.pending()).toBe(1)
writer.release()
// The writer's body is done and its async post-execute is running; the
// parallel read must not have STARTED (no pre/body) while the exclusive
// call's pipeline is still open.
await expect.poll(() => stages).toContain('post-enter:writer')
expect(reader.pending()).toBe(0)
releasePost!()
await w
await expect.poll(() => reader.pending()).toBe(1)
reader.releaseAll()
await r
return { logs: [], value: 'barrier-through-commit' }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(stages).toEqual(['post-enter:writer', 'post-exit:writer'])
})
it('run settlement drains a commit already in progress: the settle event lands inside the turn', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const gated = registerGated(ctx, 'safe_read', true)
const { agent, events } = fakeAgent()
let releasePost: (() => void) | undefined
ctx.on('tools/post-execute', async (postExec, _result, next): Promise<PostToolDecision> => {
if (postExec.name === 'safe_read') {
await new Promise<void>((resolve) => { releasePost = resolve })
}
return next()
})
runtime.behavior = async (request) => {
// Fire-and-forget: the program returns while the sub-call's async
// post-execute commit is mid-flight.
request.bindings[0]!.functions.safe_read!({ id: 'a' }).catch(() => 'run-over')
await expect.poll(() => gated.pending()).toBe(1)
gated.release()
await expect.poll(() => releasePost !== undefined).toBe(true)
queueMicrotask(() => { releasePost!() })
return { logs: [], value: 'returned-early' }
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
// The drain awaited the in-progress commit: the settle event exists and
// preceded the run_code turn closing (all appends happen inside
// execute()). The run's settlement aborted the sub-call's signal while
// its post-execute was mid-flight, so the native cancellation contract
// replaces the successful outcome with the aborted result — the event is
// still durable and in-turn, which is the invariant under test.
const settles = events.filter(event => event.type === 'tool/code-dispatch')
expect(settles).toHaveLength(1)
expect(settles[0]?.data).toMatchObject({ name: 'safe_read', isError: true })
})
it('post-execute and context commitment stay in submission order under out-of-order completion', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const gated = registerGated(ctx, 'safe_read', true)
const postOrder: string[] = []
ctx.on('tools/post-execute', async (postExec, _result, next): Promise<PostToolDecision> => {
if (postExec.name === 'safe_read') {
postOrder.push(String(postExec.callId))
return {
kind: 'accept' as const,
additionalContexts: [{
content: [{ type: 'text' as const, text: `ctx:${String(postExec.callId)}` }],
source: { kind: 'plugin' as const, plugin: 'order-probe' },
}],
}
}
return next()
})
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const all = Promise.all([tools.safe_read!({ id: 'a' }), tools.safe_read!({ id: 'b' })])
await expect.poll(() => gated.pending()).toBe(2)
// Complete b FIRST (out of submission order), then a.
gated.release() // releases a (FIFO gate) — invert: release twice reversed is not possible;
gated.releaseAll()
await all
return { logs: [], value: 'ordered-commit' }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
// Post-execute observed submission order regardless of completion interleave.
expect(postOrder).toEqual(['call-1:code:1', 'call-1:code:2'])
// Deferred contexts reach the outer result in the same order.
expect(result.additionalContexts?.map(c => (c.content[0] as { text: string }).text))
.toEqual(['ctx:call-1:code:1', 'ctx:call-1:code:2'])
})
it('a queued-unstarted call abandoned by run settlement logs no start event', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const gated = registerGated(ctx, 'writer', false)
const { agent, events } = fakeAgent()
const abandoned: string[] = []
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
// First exclusive call occupies the pool; the second queues unstarted.
// Both rejections are captured (abandonment fires only at settlement,
// AFTER this program has already failed — awaiting it here would deadlock).
tools.writer!({ id: 'w1' }).catch(() => 'settled-under-abort')
tools.writer!({ id: 'w2' }).catch((error: unknown) => {
abandoned.push(error instanceof Error ? error.message : String(error))
})
await expect.poll(() => gated.pending()).toBe(1)
// Fail the program while w1 is in flight and w2 is queued unstarted.
throw new Error('program failed with a queued call')
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(true)
const starts = events.filter(event => event.type === 'tool/code-dispatch-start').map(event => (event.data as { subCallId: string }).subCallId)
const settles = events.filter(event => event.type === 'tool/code-dispatch').map(event => (event.data as { subCallId: string }).subCallId)
// w1 started and settled under the abort; w2 never started and never
// settled — no start event, no settle event, binding rejected with the
// abandonment message at drain time.
expect(starts).toEqual(['call-1:code:1'])
expect(settles).toEqual(['call-1:code:1'])
expect(abandoned).toEqual(['run_code run is over (run_code settled); writer tool call abandoned'])
})
})
describe('the run_code dispatch bridge', () => {
it('bridges tool calls, returns only the curated output, and logs one event per dispatch', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
@@ -374,8 +704,14 @@ describe('the run_code dispatch bridge', () => {
expect(calls).toEqual([{ value: 'one' }, { value: 'two' }])
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
expect(dispatches.map(event => event.data)).toEqual([
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
{
parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo',
arguments: { value: 'one' }, isError: false, content: [{ type: 'text', text: 'echo:one' }],
},
{
parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo',
arguments: { value: 'two' }, isError: false, content: [{ type: 'text', text: 'echo:two' }],
},
])
expect(result.meta).toBeUndefined()
})
@@ -465,6 +801,52 @@ describe('the run_code dispatch bridge', () => {
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' })
})
it('a throwing tools/code-dispatch-log listener is contained: the unshaped content is logged', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
ctx.on('tools/code-dispatch-log', () => { throw new Error('shaper exploded') })
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
const value = await request.bindings[0]!.functions.echo!({ value: 'x' })
return { logs: [], value: value as string }
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
const settle = events.find(event => event.type === 'tool/code-dispatch')
expect(settle?.data).toMatchObject({ name: 'echo', isError: false, content: [{ type: 'text', text: 'echo:x' }] })
})
it('a throwing tools/pre-execute listener settles the sub-call without post-execute', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
const postExecuted: string[] = []
ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'echo') throw new Error('gate exploded')
return next()
})
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
if (exec.name === 'echo') postExecuted.push(exec.name)
return next()
})
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
const message = await request.bindings[0]!.functions.echo!({ value: 'x' })
.then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error))
return { logs: [], value: message }
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected success')
expect(result.value).toMatchObject({ result: 'gate exploded' })
// The pipeline failure is final: the body never ran and post-execute was
// skipped, yet the settle event still carries the error outcome.
expect(calls).toEqual([])
expect(postExecuted).toEqual([])
const settles = events.filter(event => event.type === 'tool/code-dispatch')
expect(settles).toHaveLength(1)
expect(settles[0]?.data).toMatchObject({ name: 'echo', isError: true })
})
it('a tools/pre-execute deny reaches the program as a binding rejection', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
@@ -693,19 +1075,26 @@ describe('the run_code dispatch bridge', () => {
expect((result.content[0] as { text: string }).text).toContain('requires a code runtime')
})
it('presents the program as the execute-card title', async () => {
it('presents the model-authored description as the execute-card title over the program input', async () => {
const { ctx } = await setup({ mode: 'code' })
const tool = ctx.tools.get(RUN_CODE_NAME)!
// The program is the title, mirroring how command tools label their cards
// with the command while retaining the same value in the expanded input.
expect(tool.presentCall?.({ code: 'return 1' })).toEqual({
// The description labels the card (the bash description precedent); the
// program itself remains the expanded raw input.
expect(tool.presentCall?.({ code: 'return 1', description: 'Return the constant one' })).toEqual({
card: 'generic',
title: 'return 1',
title: 'Return the constant one',
kind: 'execute',
rawInput: 'return 1',
})
})
it('rejects a whitespace-only description with a structured isError', async () => {
const { ctx } = await setup({ mode: 'code' })
const result = await runCode(ctx, 'return 1', { description: ' ' })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('invalid description')
})
it.each([
['logs only', { logs: ['printed'] }, 'printed'],
['result only', { logs: [], value: 'returned' }, 'returned'],
@@ -759,7 +1148,7 @@ describe('the run_code dispatch bridge', () => {
expect('presentResult' in tool).toBe(false)
})
it('renders non-text sub-result blocks as placeholders and truncates long event summaries', async () => {
it('logs the complete sub-result content verbatim, non-text blocks and long text included', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
const long = 'x'.repeat(300)
@@ -786,58 +1175,10 @@ describe('the run_code dispatch bridge', () => {
expect(result.isError).toBe(false)
expect((result.content[0] as { text: string }).text).toBe('mixed-value')
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
expect(dispatch.resultSummary.length).toBe(201)
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
})
it('normalizes the session workspace root before bounding durable result summaries', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
name: 'workspace_path',
description: 'Return a path beneath the session workspace.',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
execute(_args, exec) {
const cwd = exec.agent?.session.header.cwd ?? ''
return Promise.resolve(`<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}`)
},
}))
runtime.behavior = async request => ({
logs: [],
value: await request.bindings[0]!.functions.workspace_path!({}),
})
const short = fakeAgent({ cwd: '/tmp/workspace' })
const long = fakeAgent({ cwd: `/tmp/${'long-segment/'.repeat(30)}workspace` })
const shortResult = await runCode(ctx, 'program', { agent: short.agent })
const longResult = await runCode(ctx, 'program', { agent: long.agent })
const shortDispatch = short.events[0]!.data as SessionEventMap['tool/code-dispatch']
const longDispatch = long.events[0]!.data as SessionEventMap['tool/code-dispatch']
expect(shortResult.content).not.toEqual(longResult.content)
expect(shortDispatch.resultSummary).toBe(longDispatch.resultSummary)
expect(shortDispatch.resultSummary).toHaveLength(201)
expect(shortDispatch.resultSummary).toMatch(/^<path>\.\/nested\/task\.txt<\/path>\n.+…$/)
})
it('leaves result summaries unchanged when a session cwd is absent or is the filesystem root', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
runtime.behavior = async request => ({
logs: [],
value: await request.bindings[0]!.functions.echo!({ value: '/workspace/value' }),
})
const absent = fakeAgent({})
const root = fakeAgent({ cwd: '/' })
await runCode(ctx, 'program', { agent: absent.agent })
await runCode(ctx, 'program', { agent: root.agent })
expect((absent.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
expect(dispatch.content).toEqual([
{ type: 'text', text: long },
{ type: 'reasoning', text: 'hidden' },
])
})
it('rejects undefined, getter-throwing, exotic, and unrepresentable binding arguments before dispatch', async () => {
@@ -1067,13 +1408,27 @@ describe('the run_code dispatch bridge', () => {
name: 'echo',
arguments: { value: 'x' },
isError: false,
resultSummary: 'echo:x',
content: [{ type: 'text', text: 'echo:x' }],
})
const derived = session.deriveMessages()
expect(derived).toHaveLength(1)
expect(derived[0]?.role).toBe('user')
})
it('direct construction rejects a non-positive parallel sub-call cap at load', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
expect(() => new ToolRegistry(ctx, { mode: 'code', maxParallelSubCalls: 0 }))
.toThrow('maxParallelSubCalls must be a positive integer')
})
it('direct construction in code mode defaults the parallel sub-call cap', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
const registry = new ToolRegistry(ctx, { mode: 'code' })
expect(registry.get(RUN_CODE_NAME)).toBeDefined()
})
it('defaults to native mode under direct construction with no config', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})

View File

@@ -144,7 +144,7 @@ describe('renderToolsSdk', () => {
// The fixed instruction lines the model relies on.
expect(text).toContain('erasable syntax only')
expect(text).toContain('rejects with `ToolCallError`')
expect(text).toContain('sequentially, even under `Promise.all`')
expect(text).toContain('MAY overlap under `Promise.all`')
expect(text).toContain('lossless JSON')
})

View File

@@ -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
README.md: ef76bbcbd80ef5007426c2fea8537eceec3d4577
README.zh.md: 7eace737104310ac29f0c1e9db6d77aa911b8439
README.md: bbc41f1e0aa0c98a6e70ee54357675f1d7f05dbc
README.zh.md: 03e1246d5358138c633d2b19a9c186a3beec5a1e

View File

@@ -29,7 +29,7 @@ The app does not install commands, user interaction, session navigation, configu
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home shared by bash and local skill discovery. |
| `sessionTitle` | spine example limits | Durable fallback-title limits; titles remain off the ACP wire. |
| `persistenceRoot` | `./.sessions` | JSONL backend root and parent directory of the derived `session-query.db` index. |
| `packChunks` | `false` | Pack consecutive delta-chunk events in storage. |
| `packChunks` | `true` | Pack consecutive delta-chunk events in storage. |
| `persistenceCompression` | `zstd` | Checksummed Zstandard frames or raw `none`. |
| `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. |
| `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. |

View File

@@ -29,7 +29,7 @@ ACP 自动化服务器应用:默认 agent 主干、客户端通过 [`@deepseek
| `dshHome` | `$DSH_HOME``~/.dsh` | bash 与本地 skill 发现共享的 harness 主目录。 |
| `sessionTitle` | 主干示例限制 | 持久后备标题限制;标题仍不会进入 ACP wire。 |
| `persistenceRoot` | `./.sessions` | JSONL 后端根目录,以及派生 `session-query.db` 索引的父目录。 |
| `packChunks` | `false` | 在存储中打包连续的增量 chunk 事件。 |
| `packChunks` | `true` | 在存储中打包连续的增量 chunk 事件。 |
| `persistenceCompression` | `zstd` | 带校验和的 Zstandard 帧,或原始 `none`。 |
| `workspaceContext` | 必填 | Workspace 指令字节预算/配置,或 `false`。 |
| `skills` | 拥有者默认值 | Skill 注册表、本地提供方和面向模型的 skill 工具。 |

View File

@@ -55,7 +55,7 @@ export interface Config {
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `true`. */
packChunks?: boolean
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
@@ -89,7 +89,7 @@ export const Config: z<Config> = z.object({
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
packChunks: z.boolean().default(false),
packChunks: z.boolean().default(true),
persistenceCompression: JsonlCompressionSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,

View File

@@ -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
README.md: 736de2ea01e1524854c57f91d128b82a9fe0c9e8
README.zh.md: 4ffe47ba82539d12c9b74b1690392d58d21a24b1
README.md: 32874bf2839c194572ddde8c4ed007297f763ccc
README.zh.md: 57a06a00203b8e67f2f33c87d7450d1a0789d7e6

View File

@@ -24,7 +24,7 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-tasks-local generic background-task registry
@deepseek-ai/dsh-invariants configurable invariant registry service
@deepseek-ai/dsh-session/invariant
@deepseek-ai/dsh-agent/invariant

View File

@@ -24,7 +24,7 @@
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-tasks-local generic background-task registry
@deepseek-ai/dsh-invariants configurable invariant registry service
@deepseek-ai/dsh-session/invariant
@deepseek-ai/dsh-agent/invariant

View File

@@ -42,7 +42,7 @@
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tasks-local": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-goal": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
@@ -74,6 +74,7 @@
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",

View File

@@ -22,7 +22,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal'
import * as goalSession from '@deepseek-ai/dsh-goal-session'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
import TaskService from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants'
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
@@ -223,7 +223,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(toolGoal, config.goals.tool ?? {})
ctx.plugin(goalSession)
}
ctx.plugin(TaskService)
ctx.plugin(LocalTaskService)
ctx.plugin(InvariantService, config.invariants ?? {})
ctx.plugin(sessionInvariant)
ctx.plugin(agentInvariant)

View File

@@ -74,6 +74,9 @@
{
"path": "../../tasks/tasks"
},
{
"path": "../../tasks/tasks-local"
},
{
"path": "../../tasks/tool-tasks"
}

View File

@@ -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
README.md: a40b70e52172ae340321be53b127b7064c501f66
README.zh.md: 1533e815429f845efe524c5fa2fa191708fd494b
README.md: 9e6c954abad124fb2b30ebc01368a55746752013
README.zh.md: 262d689be916c2983b203072c713a924b35fc3af

View File

@@ -57,6 +57,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.

View File

@@ -57,6 +57,6 @@
## 已知限制与延期工作
- **契约只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
- **只有八个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **只有八个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。
- **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。

View File

@@ -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
README.md: 1e725d13af70bdb2f326b43350323763f95b579b
README.zh.md: fe749ca1032b576bef10c1dbdfd41d6299b6cf50
README.md: 981f7d58802d2ff18633b09b5a4ec8a7b1bf3383
README.zh.md: 0a6535f41adb8ec90d02dbb56aec257853a19083

View File

@@ -73,8 +73,8 @@ Pass-through; the registry preserves the assembled request prefix, while the sel
## Known Limitations and Deferred Work
- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains a single-attempt call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. `@deepseek-ai/dsh-llm-retry` is an optional policy plugin loaded by the shared example spine.
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)).
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)).
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.
- **`APP_IDENTITY.url` names a repository that does not exist yet** — `FIXME`: creating the public `deepseek-ai/deepseek-harness-sdk` repo gates the first release.
- **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround.

View File

@@ -73,8 +73,8 @@
## 已知限制与暂缓事项
- **本服务不内置默认重试/缓存/速率限制策略**`llm/stream` 仍是单次尝试调用包装 seamagent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败。`@deepseek-ai/dsh-llm-retry` 是共享示例 spine 加载的可选策略插件。
- **`GenerateOptions` 采样只包含 `temperature``maxTokens``stop`**:没有 `tool_choice``top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md))。
- **由产生方调节的变体在实际产生前保持在外**`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。
- **`GenerateOptions` 采样只包含 `temperature``maxTokens``stop`**:没有 `tool_choice``top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。
- **由产生方调节的变体在实际产生前保持在外**`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。
- **`BlockAssembler` 只处理核心块 kind**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。
- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**`FIXME`:创建公开 `deepseek-ai/deepseek-harness-sdk` 仓库是首次发布的前置条件。
- **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。

View File

@@ -47,8 +47,10 @@ export function markLlmAdapterFailure(
const error = value instanceof Error
? value as Error & { code?: string }
: new HarnessError(String(value), 'UNKNOWN', { cause: value })
const carried = error instanceof HarnessError ? ownFailureSnapshot(error) : undefined
const failure = carried !== undefined && carried.code === error.code ? carried : Object.freeze({
// Cross-package copies preserve own data but not class identity. Trust the
// carried facts only when both own properties agree after validation.
const carried = ownFailureSnapshot(error)
const failure = carried !== undefined && carried.code === ownErrorCode(error) ? carried : Object.freeze({
message: errorMessage(error),
code: harnessErrorCode(error),
})
@@ -56,6 +58,16 @@ export function markLlmAdapterFailure(
return error
}
/** Read a foreign error's own data-backed `code` without invoking accessors. */
function ownErrorCode(error: Error): unknown {
try {
const descriptor = Object.getOwnPropertyDescriptor(error, 'code')
return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined
} catch (_sdkPropertyTrap) {
return undefined
}
}
/** Snapshot an own data property without invoking an SDK-defined accessor. */
function ownFailureSnapshot(error: Error): LlmFailure | undefined {
try {

View File

@@ -291,6 +291,34 @@ describe('LlmService', () => {
expect(facts).not.toBe(carried)
})
it('keeps validated failure facts across package copies with matching own codes', async () => {
const original = Object.assign(new Error('provider busy'), {
code: 'RATE_LIMIT',
failure: {
message: 'provider busy',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 1_500,
requestId: 'req-cross-copy',
},
})
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(llmFailureOf(stream, original)).toEqual({
message: 'provider busy',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 1_500,
requestId: 'req-cross-copy',
})
})
it('keeps an unknown SDK Error exact without trusting its private code or accessors', async () => {
const original = Object.assign(new Error('socket closed'), { code: 'ECONNRESET' })
Object.defineProperty(original, 'failure', {
@@ -324,6 +352,64 @@ describe('LlmService', () => {
expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
})
it('keeps an SDK Error exact without trusting accessor-backed carried facts', async () => {
const original = Object.assign(new Error('busy'), {
failure: { message: 'busy', code: 'SERVER', status: 503 },
})
Object.defineProperty(original, 'code', {
get() { throw new Error('SDK code accessor must not escape') },
})
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' })
})
it('does not trust carried facts matched only by an inherited code', async () => {
class InheritedCodeError extends Error {
get code(): string { return 'SERVER' }
}
const original = Object.assign(new InheritedCodeError('busy'), {
failure: { message: 'busy', code: 'SERVER', status: 503 },
})
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' })
})
it('keeps an SDK Error exact when code descriptor inspection is trapped', async () => {
const target = Object.assign(new Error('busy'), {
code: 'SERVER',
failure: { message: 'busy', code: 'SERVER', status: 503 },
})
const original = new Proxy(target, {
getOwnPropertyDescriptor(value, property) {
if (property === 'code') throw new Error('SDK code descriptor trap')
return Reflect.getOwnPropertyDescriptor(value, property)
},
})
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' })
})
it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => {
const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), {
getOwnPropertyDescriptor(target, property) {

View File

@@ -752,7 +752,7 @@ describe('exit_plan_mode', () => {
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: RUN_CODE_NAME,
arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })` },
arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })`, description: 'Submit the plan for review' },
signal: new AbortController().signal,
agent,
})

View File

@@ -135,12 +135,16 @@ describe('pty-local real shell', () => {
const { ctx, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const controller = new AbortController()
const ready = 'RAW_READY'
// The interactive shell echoes the command, so only child output may contain the readiness marker.
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\''
expect(command).not.toContain(ready)
const foreground = ctx.pty.startSend(agent, created.sessionId, {
text: 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_READY", flush=True); time.sleep(60)\'',
text: command,
submit: true,
signal: controller.signal,
})
await waitForOutput(foreground, 'RAW_READY')
await waitForOutput(foreground, ready)
controller.abort()
const result = await foreground.done
expect(result.waitReason).toBe('stdin_read')

View File

@@ -54,6 +54,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -9,7 +9,7 @@ import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools'
import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
import TaskService from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
@@ -106,7 +106,7 @@ async function setupBase(tasks: boolean) {
const stub = stubBackend()
ctx.pty.registerBackend(stub.backend)
if (tasks) {
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
}
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }

View File

@@ -1,4 +1,4 @@
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -18,11 +18,17 @@ const sessionId = SessionId('semantic-checkpoint-crash')
const roots: string[] = []
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
async function waitForFile(path: string): Promise<void> {
await vi.waitFor(async () => {
await access(path).catch((error: unknown) => {
throw new Error(`crash child did not reach failpoint ${path}`, { cause: error })
async function waitForMarker(path: string, expected: string): Promise<string> {
return await vi.waitFor(async () => {
const content = await readFile(path, 'utf8').catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error })
})
if (content === expected) return content
if (!expected.startsWith(content)) {
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
}
throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`)
}, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
}
@@ -30,6 +36,9 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker
const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`))
roots.push(root)
const marker = join(root, 'failpoint')
// Keep the open-before-write window deterministic: readiness is marker content, not path existence.
await writeFile(marker, '')
const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect'
// The SIGKILL-at-failpoint choreography stays custom: the child must die
// mid-write, so no timeout or graceful termination may reach it first.
const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
@@ -40,8 +49,7 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker
reject: false,
})
try {
await waitForFile(marker)
const markerText = await readFile(marker, 'utf8')
const markerText = await waitForMarker(marker, expectedMarker)
child.kill('SIGKILL')
const exit = await child
expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })

View File

@@ -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
README.md: a0d718cf8bd0090df0409e7c60e6f7fd559b6f7d
README.zh.md: 307bef8efb506c2df7ef229e85b3224a8e7c29e1
README.md: ab6ecd28f12bd167aeac789d1565705e167d60f4
README.zh.md: 97d387a04fa4c658217e28619410a49b7e6d4ec0

View File

@@ -15,7 +15,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
```
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.
@@ -24,7 +24,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
| Key | Type | Notes |
|---|---|---|
| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). An existing root must be a readable directory; an absent root is created on first materialization. |
| `packChunks` | `boolean` (default `false`) | Write delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Off, the written logical layout is byte-identical to the pre-packing format; reading packed rows works regardless of this switch. Off by default while the snapshot goldens stay one-event-per-line — recording with packing on rewrites every fixture `session.jsonl`. |
| `packChunks` | `boolean` (default `true`) | Write eligible delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Set `false` for one-event-per-line diagnostics; reading packed rows works regardless of this write-side switch. |
| `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. |
`locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix.

View File

@@ -15,7 +15,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence``dsh-ses
```
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }``delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 存储记录是原样 `SessionEvent` JSON`packChunks` 写入的**打包分片行**`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session``packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist任何未识别内容原样存储。读取与布局无关`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
- 存储记录是原样 `SessionEvent` JSON或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session``packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist任何未识别内容原样存储。读取与布局无关`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
- 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。
@@ -24,7 +24,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence``dsh-ses
| 键 | 类型 | 说明 |
|---|---|---|
| `root` | `string` (required) | 所有会话文件的根目录。**无默认值**`process.cwd()` 默认值会随进程 cwd 变更bash 调用、子进程)而分散文件。现有根必须是可读目录;缺失根在第一次实体化时创建。 |
| `packChunks` | `boolean` (default `false`) | 将 delta 分片运行写为打包行(在真实编码会话上测得逻辑日志约小 60%)。关闭时,写入逻辑布局与打包前格式字节相同;无论开关如何,都能读取打包行。快照预期输出仍是每事件一行时默认关闭:开启打包记录会重写每个 fixture `session.jsonl`。 |
| `packChunks` | `boolean` (default `true`) | 将符合条件的 delta 分片连续段写为打包行(在真实编码会话上测得逻辑日志约小 60%)。设为 `false` 可用于每事件一行诊断;无论该写入侧开关如何,都能读取打包行。 |
| `compression` | `'zstd' \| 'none'` | 默认 `'zstd'``'none'` 保留换行分隔 UTF-8 文本。 |
`locate(meta)` 返回已解析项目/会话目录内固定 transcript 的 `{ kind: 'jsonl', path }`。它不执行文件系统 I/O可以在目录或文件存在前返回目标现有文件也只包含最后 flush 前缀。

View File

@@ -27,6 +27,7 @@ import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
export type { JsonlCompression } from './format.ts'
const DEFAULT_PACK_CHUNKS = true
const DEFAULT_COMPRESSION: JsonlCompression = 'zstd'
/** Loader schema for the JSONL artifact's physical encoding. */
@@ -48,10 +49,9 @@ export interface Config {
/**
* Write runs of consecutive `assistant/chunk` delta events as packed
* `text-chunks`/`reasoning-chunks`/`tool-call-chunks` rows (lossless,
* ~60% smaller logs measured on a real session). Off by default while
* snapshot fixtures stay in the one-event-per-line layout: recording with
* packing on rewrites every golden `session.jsonl`. READING packed rows is
* unconditional — a log's layout never depends on this switch.
* ~60% smaller logs measured on a real session). Defaults to true; false
* keeps one `SessionEvent` per line for diagnostics. Reading packed rows is
* unconditional: a log's layout never depends on this switch.
*/
packChunks?: boolean
/** Physical encoding; defaults to checksummed Zstandard frames. */
@@ -80,7 +80,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
static Config: z<Config> = z.object({
root: z.string().required(),
packChunks: z.boolean().default(false),
packChunks: z.boolean().default(DEFAULT_PACK_CHUNKS),
compression: JsonlCompressionSchema,
})
@@ -101,9 +101,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
super(ctx)
// Resolve once so later process.cwd() changes cannot split one backend across roots.
this.root = resolve(config.root)
// schemastery (static Config) applied the default before construction;
// the cast records that runtime fact for exactOptionalPropertyTypes.
this.packChunks = (config as Required<Config>).packChunks
// Programmatic wrappers may construct the backend without Schemastery normalization.
this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS
this.compression = config.compression ?? DEFAULT_COMPRESSION
this.assertUsableRoot()
this.coordinator = new PersistenceCoordinator<JsonlTornMarker>(this.ctx, this)

View File

@@ -724,7 +724,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
})
})
describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () => {
describe('SessionPersistenceJsonl: default packed chunk rows', () => {
let ctx: Context
beforeEach(async () => {
root = await freshRoot()
@@ -732,7 +732,7 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () =>
await ctx.plugin(SessionStore)
// compression: 'none' — these tests assert the textual storage-record layout
// (row tags per line); packing is orthogonal to the physical encoding.
await ctx.plugin(SessionPersistenceJsonl, { root, packChunks: true, compression: 'none' })
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
})
afterEach(async () => { await ctx.fiber.dispose() })
@@ -754,7 +754,7 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () =>
]
}
it('writes a delta run as one text-chunks row and loads back identical events', async () => {
it('writes a delta run as one text-chunks row by default and loads back identical events', async () => {
const m = meta('packed', '/work')
const log = chunkRunLog()
await ctx.sessionPersistence.create(m)
@@ -768,6 +768,32 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () =>
expect(loaded.events).toEqual(log)
})
it('packChunks: false writes one event per line and still loads identical events', async () => {
const unpackedRoot = await freshRoot()
const unpacked = new Context()
await unpacked.plugin(SessionStore)
await unpacked.plugin(SessionPersistenceJsonl, {
root: unpackedRoot,
packChunks: false,
compression: 'none',
})
try {
const m = meta('unpacked', '/work')
const log = chunkRunLog()
await unpacked.sessionPersistence.create(m)
await unpacked.sessionPersistence.append(m.id, log)
const records = (await readFile(rawLogPath(unpackedRoot, '/work', m.id), 'utf8'))
.split('\n').filter(Boolean).slice(1)
.map(line => JSON.parse(line) as { type: string })
expect(records.filter(record => record.type === 'assistant/chunk')).toHaveLength(5)
expect(records.some(record => record.type === 'text-chunks')).toBe(false)
expect((await unpacked.sessionPersistence.load(m.id)).events).toEqual(log)
} finally {
await unpacked.fiber.dispose()
}
})
it('loads a mixed file: verbatim lines from an unpacked writer, then packed appends', async () => {
const m = meta('mixed', '/work')
const log = chunkRunLog()

View File

@@ -245,10 +245,35 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
backend = new SessionPersistenceJsonl(inner, { root })
}, { inject: ['sessions'] }))
const header = meta('direct-default')
const path = logPath(root, header.cwd, header.id, 'zstd')
expect(backend.locate(header)).toEqual({
kind: 'jsonl',
path: logPath(root, header.cwd, header.id, 'zstd'),
path,
})
const base = oneTurnLog()
const events: SessionEvent[] = [
...base.slice(0, 3),
...Array.from({ length: 3 }, (_, index): SessionEvent => ({
type: 'assistant/chunk',
seq: 3 + index,
time: 4 + index,
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `part-${index}` } },
})),
...base.slice(3).map((event): SessionEvent => ({
...event,
seq: event.seq + 3,
time: event.time + 3,
})),
]
await backend.create(header)
await backend.append(header.id, events)
const plaintext = (await decodeCompleteFrames(await readFile(path))).toString()
const recordTypes = plaintext.trimEnd().split('\n')
.map(line => (JSON.parse(line) as { type: string }).type)
expect(recordTypes).toContain('text-chunks')
expect((await backend.load(header.id)).events).toEqual(events)
})
it('appends one frame per durable batch without rewriting prior bytes', async () => {

View File

@@ -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
README.md: ed72e0d73ec83a6cb8620245e3793770ce622ab3
README.zh.md: db0be6153465ee64bce7f9ab9a2a3c1dddf8b53d
README.md: 7638f62c0426964d83d7a635ebf73d0f117abd78
README.zh.md: bf6d52a8c6b2b2e56052075af4a2150fba357f84

View File

@@ -15,7 +15,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
## Behavior
1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted).
2. Skip nested executions (`exec.parent` is present), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through).
2. Skip nested executions (`exec.parent` is present — their DURABLE copy is bounded by the dispatch-log arm below), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through).
3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched.
4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged.
5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap:
@@ -30,6 +30,8 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. A successful replacement changes only `content`; the canonical programmatic value is preserved.
**The dispatch-log arm:** a second listener on `tools/code-dispatch-log` applies the same cap, replacement pipeline, and best-effort fallbacks to the DURABLE copy of each `run_code` sub-call result (artifact label `dispatch`, keyed by the sub-call id). The program's value is untouched — it already crossed the worker boundary whole — and `read` sub-calls are bounded too: a log copy is not model context, so the read-again loop cannot occur, and `read` is precisely the tool that produces huge logs ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
## Scope
The policy sees only the FINAL formatted surface result—not a tool's internal resource or canonical value. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. `glob`/`grep` own item-level surface spill because their complete acquired values still exist before rendering; bash streams own acquisition-time spill. The generic policy prepends its waterfall listener, then delegates, so ordinary tool-owned asynchronous projections complete before generic byte bounding regardless of plugin load order. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md).

View File

@@ -15,7 +15,7 @@
## 行为
1. 允许工具运行(通过 `next()` 委托,因此可以限制任何下游钩子接受的内容)。
2. 跳过嵌套执行(存在 `exec.parent`)、已接受的值替换(注册表必须重新验证并渲染它们)、`read`(避免 `read → spill → read again` 循环)以及任何非 `accept` 决定(`block` 的纠正反馈会原样通过)。
2. 跳过嵌套执行(存在 `exec.parent`——其持久副本由下方的 dispatch-log 分支设界)、已接受的值替换(注册表必须重新验证并渲染它们)、`read`(避免 `read → spill → read again` 循环)以及任何非 `accept` 决定(`block` 的纠正反馈会原样通过)。
3. 仅在已接受的内容为**纯文本**(全部都是 `text` 块)时才将其展平;包含任何非文本块的结果都保持不变。
4. 如果 UTF-8 大小为 `≤ maxInlineBytes`,则保持不变。
5. 否则,保存完整文本,并将结果替换为预览和以下通知。系统会调整大小,使整个替换内容(预览、空行和通知)不超过 `maxInlineBytes`:先从预算中保留通知所需字节,再缩小预览以适配剩余空间,因此面向模型的结果绝不会超过上限:
@@ -30,6 +30,8 @@
**尽力而为**:没有会话 owner、没有 `ctx.spillStore` 后端,或 `saveText` 拒绝 ⇒ 策略记录警告并返回原始结果。spill 失败绝不会将成功调用变为 `isError`,也不会隐藏内联结果。成功替换时只会更改 `content`;规范程序值保持不变。
**dispatch-log 分支:**注册在 `tools/code-dispatch-log` 上的第二个监听器,把同一套上限、替换流水线与尽力而为的回退应用到每个 `run_code` 子调用结果的持久副本上(工件标签为 `dispatch`,按子调用 id 归档)。程序取得的值不受影响——它早已完整跨过 worker 边界;`read` 子调用同样设界:日志副本不是模型上下文,因此不会发生 read-again 循环,而 `read` 恰恰是最容易产生巨型日志的工具([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
## 范围
该策略只能看到最终格式化接口结果,看不到工具的内部资源或规范值。如果提供方已经截断内容(例如 `web-fetch-local.maxBodyChars`spill 产物保存的是工具返回的完整格式化结果,而非完整原始源。提供方/资源上限仍必须存在,并且与该策略分离。`glob`/`grep` 负责对项级接口结果执行 spill因为渲染前仍然存在完整的已获取值bash 流负责在获取时 spill。通用策略预先注册自己的 waterfall 监听器,然后再委托,因此无论插件加载顺序如何,普通工具拥有的异步投影都会在通用字节限制之前完成。详见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。

View File

@@ -10,18 +10,26 @@
* `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`.
* The policy only decides WHEN to spill and composes the notice.
*
* A second arm applies the SAME cap to the durable log: the
* `tools/code-dispatch-log` waterfall bounds the `tool/code-dispatch` event's
* copy of an oversized `run_code` sub-call result (the program's value is
* untouched; UIs and replay read the full text through the spill artifact).
*
* ## Deliberately narrow
*
* - Omitted `maxInlineBytes` ⇒ the plugin registers nothing (a true no-op).
* - Plain-text results only: a result carrying any non-text block is left
* untouched (the policy knows only the final formatted text, not tool
* internals).
* - Nested composite calls are skipped; only their outer surface result may
* become model-facing and spillable.
* - Nested composite calls skip the MODEL-facing arm; their durable log copy
* is bounded by the dispatch-log arm instead.
* - Accepted value replacements pass through for registry revalidation and
* rendering; this presentation policy cannot also replace content in the
* same mutually exclusive decision.
* - `read` is skipped to avoid a `read → spill → read again` loop.
* - `read` is skipped by the model-facing arm to avoid a
* `read → spill → read again` loop; the dispatch-log arm bounds `read`
* sub-calls too (a log copy is not model context, and `read` is precisely
* the tool that produces huge logs).
* - Best-effort: no session owner, no `ctx.spillStore` backend, or a save
* failure ⇒ log and return the original result. A spill failure must NEVER
* turn a successful tool call into an `isError` or hide the inline result.
@@ -42,6 +50,7 @@ import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention'
import type { Omitted } from '@deepseek-ai/dsh-retention'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
import type { SpillPolicyExec } from './types.ts'
@@ -108,6 +117,75 @@ export function apply(ctx: Context, config: Config): void {
if (!Number.isInteger(maxInlineBytes) || maxInlineBytes < 0) {
throw new Error(`spill-policy: maxInlineBytes must be a non-negative integer (got ${maxInlineBytes})`)
}
// Narrowed once for the nested arms (closure narrowing does not survive awaits).
const cap: number = maxInlineBytes
/**
* Spill `text` and build the bounded replacement (preview + notice), or
* return `undefined` when the policy must keep the original (no session
* owner, no backend, storage failure, or no within-cap replacement).
* Shared verbatim by the model-facing post-execute arm and the durable
* dispatch-log arm so both produce byte-identical projections.
*/
async function spillReplacement(
text: string,
totalBytes: number,
sessionId: SessionId | undefined,
toolName: string,
callId: CallId,
label: 'result' | 'dispatch',
): Promise<string | undefined> {
if (sessionId === undefined) {
ctx.logger.warn(`spill-policy: no session owner for ${toolName} ${label}; keeping the inline content`)
return undefined
}
const spillStore = ctx.get('spillStore')
if (!spillStore) {
ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline content')
return undefined
}
const save: SaveTextSpill = {
owner: { sessionId },
source: { toolName, callId, label },
suggestedName: `${toolName}.txt`,
content: text,
}
let ref: SpillRef
try {
ref = await spillStore.saveText(save)
} catch (error: unknown) {
// Best-effort: a storage failure (permissions, ENOSPC, backend down) must
// never fail the call or hide the content — keep the original inline.
ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`)
return undefined
}
// Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement
// (preview + blank line + notice) never exceeds the documented cap — a naive
// preview that spent the whole budget then appended the notice could be
// larger than the cap, and for a marginally-over result even larger than the
// original. The reservation uses a notice priced at the worst-case omission
// count (the full byte total): its digit count bounds the real count's, so
// the reserved size is a safe upper bound and the final notice is never
// longer than what we reserved. `\n\n` is the 2-byte join.
const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
const previewBudget = Math.max(0, cap - reserve)
const { text: previewText, omitted } = preview(text, previewBudget)
const notice = spillNotice(omitted, ref)
const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice
// Invariant: the policy NEVER emits a replacement larger than the cap. When
// the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root),
// there is no within-cap replacement, so keep the inline content — spilling
// would break the advertised cap. (A within-cap replacement is always
// smaller than the original, which is > cap by the entry condition, so this
// one check subsumes "not smaller than the original" too. The spill file
// already written is a harmless orphan; cleanup is deferred.)
if (Buffer.byteLength(replacedText, 'utf8') > cap) {
ctx.logger.warn(`spill-policy: spill notice for ${toolName} exceeds maxInlineBytes; keeping the inline content`)
return undefined
}
return replacedText
}
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
// Delegate first so a downstream listener (e.g. a hook) settles the result;
@@ -124,58 +202,31 @@ export function apply(ctx: Context, config: Config): void {
const totalBytes = Buffer.byteLength(text, 'utf8')
if (totalBytes <= maxInlineBytes) return decision
const sessionId = ownerSessionId(exec)
if (sessionId === undefined) {
ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`)
return decision
}
const spillStore = ctx.get('spillStore')
if (!spillStore) {
ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result')
return decision
}
const save: SaveTextSpill = {
owner: { sessionId },
source: { toolName: exec.name, callId: exec.callId, label: 'result' },
suggestedName: `${exec.name}.txt`,
content: text,
}
let ref: SpillRef
try {
ref = await spillStore.saveText(save)
} catch (error: unknown) {
// Best-effort: a storage failure (permissions, ENOSPC, backend down) must
// never fail the call or hide the result — keep the original inline.
ctx.logger.warn(`spill-policy: saveText failed for ${exec.name}: ${String(error)}; keeping the inline result`)
return decision
}
// Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement
// (preview + blank line + notice) never exceeds the documented cap — a naive
// preview that spent the whole budget then appended the notice could be
// larger than the cap, and for a marginally-over result even larger than the
// original. The reservation uses a notice priced at the worst-case omission
// count (the full byte total): its digit count bounds the real count's, so
// the reserved size is a safe upper bound and the final notice is never
// longer than what we reserved. `\n\n` is the 2-byte join.
const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
const previewBudget = Math.max(0, maxInlineBytes - reserve)
const { text: previewText, omitted } = preview(text, previewBudget)
const notice = spillNotice(omitted, ref)
const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice
// Invariant: the policy NEVER emits a replacement larger than the cap. When
// the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root),
// there is no within-cap replacement, so keep the inline result — spilling
// would break the advertised context cap. (A within-cap replacement is
// always smaller than the original, which is > cap by the entry condition,
// so this one check subsumes "not smaller than the original" too. The spill
// file already written is a harmless orphan; cleanup is deferred.)
if (Buffer.byteLength(replacedText, 'utf8') > maxInlineBytes) {
ctx.logger.warn(`spill-policy: spill notice for ${exec.name} exceeds maxInlineBytes; keeping the inline result`)
return decision
}
const replacedText = await spillReplacement(text, totalBytes, ownerSessionId(exec), exec.name, exec.callId, 'result')
if (replacedText === undefined) return decision
const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }]
return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} }
}, { prepend: true })
// The durable-log arm: bound the `tool/code-dispatch` event's copy of an
// oversized sub-call result the same way the model-facing arm bounds an
// outer result. The program's returned value is untouched (it already
// crossed the worker boundary whole); only the session log's copy shrinks
// to preview + locator, so replay and UIs read the full text through the
// spill artifact exactly as they do for spilled native results.
ctx.on('tools/code-dispatch-log', async (dispatch, next): Promise<ContentBlock[]> => {
const content = await next()
// `read` sub-calls spill too: the log copy is not model context, so the
// read → spill → read-again loop the post-execute arm avoids cannot
// happen here, and read is precisely the tool that produces huge logs.
const text = flattenPlainText(content)
if (text === undefined) return content
const totalBytes = Buffer.byteLength(text, 'utf8')
if (totalBytes <= maxInlineBytes) return content
const replacedText = await spillReplacement(
text, totalBytes, ownerSessionId(dispatch.exec), dispatch.name, dispatch.subCallId, 'dispatch')
if (replacedText === undefined) return content
return [{ type: 'text', text: replacedText }]
}, { prepend: true })
}

View File

@@ -16,6 +16,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
@@ -28,9 +29,12 @@ const testToolSignal = new AbortController().signal
class StubStore extends SpillStore {
saves: SaveTextSpill[] = []
fail = false
/** Per-save hang hook: each call awaits the returned promise before completing. */
gate: (() => Promise<void>) | undefined
async saveText(input: SaveTextSpill): Promise<SpillRef> {
if (this.fail) throw new Error('disk full')
await this.gate?.()
this.saves.push(input)
return {
locator: SpillLocator(`/spill/${input.suggestedName}`),
@@ -204,6 +208,7 @@ describe('outer Code Mode failure capture', () => {
name: 'run_code',
arguments: {
code: 'console.log("HEAD-" + "x".repeat(300)); console.log("TAIL-" + "y".repeat(300)); return "unreachable";',
description: 'Print oversized head and tail lines',
},
agent: agent as never,
})
@@ -229,6 +234,230 @@ describe('read skip', () => {
})
})
describe('the durable dispatch-log arm', () => {
/** Boot code mode + the policy + the worker runtime; run one program via the real bridge. */
async function runCodeWith(program: string, maxInlineBytes: number, extraTools: ToolDefinition[] = []) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(StubStore)
await ctx.plugin(SpillPolicy, { maxInlineBytes })
await ctx.plugin(WorkerCodeRuntime, {})
const events: { type: string; data: unknown }[] = []
const agent = {
session: {
header: { id: SessionId('dispatch-spill'), cwd: '/workspace' },
append: (type: string, data: unknown) => { events.push({ type, data }) },
},
}
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
ctx.tools.register(textTool('small_read', 'tiny'))
for (const tool of extraTools) ctx.tools.register(tool)
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('parent-1'),
name: 'run_code',
arguments: { code: program, description: 'Drive dispatch-log spilling' },
agent: agent as never,
})
return { ctx, result, events, spill: ctx.spillStore as StubStore }
}
it('bounds the tool/code-dispatch copy of an oversized sub-result while the program value stays whole', async () => {
const { result, events, spill } = await runCodeWith(
'const blocks = await tools.huge_read({});\nreturn blocks[0].text.length', 200)
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected success')
// The program received the COMPLETE text (length 2000), untouched by spill.
expect(result.value).toMatchObject({ result: 2_000 })
// The durable settle event carries the bounded projection + locator.
const settle = events.find(event => event.type === 'tool/code-dispatch')
expect(settle).toBeDefined()
const logged = (settle!.data as { content: { type: string; text: string }[] }).content
expect(logged).toHaveLength(1)
const loggedText = logged[0]!.text
expect(Buffer.byteLength(loggedText, 'utf8')).toBeLessThanOrEqual(200)
expect(loggedText).toContain('Full formatted result stored at: /spill/huge_read.txt')
// The artifact holds the full text under the dispatch label and sub-call id.
const save = spill.saves.find(entry => entry.source.label === 'dispatch')
expect(save).toMatchObject({
source: { toolName: 'huge_read', callId: 'parent-1:code:1', label: 'dispatch' },
})
expect(save?.content).toBe('H'.repeat(2_000))
})
it('leaves a non-text sub-result log unchanged (flatten declines)', async () => {
const { events, spill } = await runCodeWith(
'return await tools.mixed_read({})', 5, [defineContentToolFixture({
name: 'mixed_read',
description: 'mixed_read',
parameters: {},
async execute(): Promise<ContentBlock[]> {
return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }]
},
})])
const settle = events.find(event => event.type === 'tool/code-dispatch')
expect((settle!.data as { content: unknown[] }).content).toHaveLength(2)
expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0)
})
it('leaves a within-cap sub-result log untouched and saves nothing for it', async () => {
const { events, spill } = await runCodeWith(
'return await tools.small_read({})', 200)
const settle = events.find(event => event.type === 'tool/code-dispatch')
expect((settle!.data as { content: { type: string; text: string }[] }).content)
.toEqual([{ type: 'text', text: 'tiny' }])
expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0)
})
it('a slow spill backend never delays the program value or a later dispatch slot', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(StubStore)
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
await ctx.plugin(WorkerCodeRuntime, {})
// A spill backend that hangs until released.
let releaseSave!: () => void
const gate = new Promise<void>((resolve) => { releaseSave = resolve })
const store = ctx.spillStore as StubStore
const realSave = store.saveText.bind(store)
store.saveText = async (input) => {
await gate
return realSave(input)
}
const events: { type: string; data: unknown }[] = []
const agent = {
session: {
header: { id: SessionId('dispatch-slow-spill'), cwd: '/workspace' },
append: (type: string, data: unknown) => { events.push({ type, data }) },
},
}
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
ctx.tools.register(textTool('small_read', 'tiny'))
let smallAfterHuge = false
const runPromise = ctx.tools.execute({
signal: testToolSignal,
callId: CallId('parent-3'),
name: 'run_code',
arguments: {
// The program takes BOTH values while the spill backend hangs: the
// huge read's binding resolves immediately (its logged copy is side
// work), so the small read proceeds without waiting.
code: 'const big = await tools.huge_read({});\nconst small = await tools.small_read({});\nreturn big[0].text.length + small[0].text.length',
description: 'Prove log shaping is off the program path',
},
agent: agent as never,
}).then((result) => {
return result
})
// The run cannot COMPLETE while the settle append is gated (drain waits
// for logWork), but the program itself already ran both calls; release
// the backend and observe the settle events land inside the turn.
await vi.waitFor(() => {
// The second dispatch STARTED while the first one's spill hung.
smallAfterHuge = events.some(event => event.type === 'tool/code-dispatch-start'
&& (event.data as { name: string }).name === 'small_read')
if (!smallAfterHuge) throw new Error('small_read not started yet')
})
releaseSave()
const result = await runPromise
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected success')
expect(result.value).toMatchObject({ result: 2_004 })
const settles = events.filter(event => event.type === 'tool/code-dispatch')
expect(settles).toHaveLength(2)
expect(smallAfterHuge).toBe(true)
})
it('a sustained slow backend backpressures the run instead of accumulating unbounded log tasks', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
// Cap 1: once the hung shaped-append backlog exceeds the cap, the ordered
// lane holds inside the second commit, so the THIRD dispatch cannot start
// until a pending save drains — the bound is observable as its missing
// start event.
await ctx.plugin(ToolRegistry, { mode: 'code', maxParallelSubCalls: 1 })
await ctx.plugin(StubStore)
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
await ctx.plugin(WorkerCodeRuntime, {})
const store = ctx.spillStore as StubStore
const releases: (() => void)[] = []
store.gate = () => new Promise<void>((resolve) => { releases.push(resolve) })
const events: { type: string; data: unknown }[] = []
const agent = {
session: {
header: { id: SessionId('dispatch-spill-bound'), cwd: '/workspace' },
append: (type: string, data: unknown) => { events.push({ type, data }) },
},
}
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
const started = (n: number): boolean => events.some(event => event.type === 'tool/code-dispatch-start'
&& (event.data as { subCallId: string }).subCallId.endsWith(`:code:${n}`))
const runPromise = ctx.tools.execute({
signal: testToolSignal,
callId: CallId('parent-bound'),
name: 'run_code',
arguments: {
code: 'await tools.huge_read({}); await tools.huge_read({}); await tools.huge_read({}); return "done"',
description: 'Three oversized reads against a hung backend',
},
agent: agent as never,
})
// Two hung saves = backlog above the cap: the lane must hold before
// starting dispatch 3.
await vi.waitFor(() => {
if (releases.length < 2) throw new Error('second hung save not reached yet')
})
expect(started(2)).toBe(true)
expect(started(3)).toBe(false)
releases.shift()!()
// Draining one pending save releases the lane; dispatch 3 starts.
await vi.waitFor(() => {
if (!started(3)) throw new Error('third dispatch not started yet')
})
while (releases.length > 0) releases.shift()!()
const result = await runPromise
expect(result.isError).toBe(false)
await vi.waitFor(() => {
if (releases.length > 0) { while (releases.length > 0) releases.shift()!() }
if (events.filter(event => event.type === 'tool/code-dispatch').length !== 3) {
throw new Error('settle events still pending')
}
})
})
it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(StubStore)
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
await ctx.plugin(WorkerCodeRuntime, {})
;(ctx.spillStore as StubStore).fail = true
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const events: { type: string; data: unknown }[] = []
const agent = {
session: {
header: { id: SessionId('dispatch-spill-fail'), cwd: '/workspace' },
append: (type: string, data: unknown) => { events.push({ type, data }) },
},
}
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('parent-2'),
name: 'run_code',
arguments: { code: 'return (await tools.huge_read({}))[0].text.length', description: 'Fail the spill backend' },
agent: agent as never,
})
expect(result.isError).toBe(false)
const settle = events.find(event => event.type === 'tool/code-dispatch')
expect((settle!.data as { content: { text: string }[] }).content[0]!.text).toBe('H'.repeat(2_000))
expect(warn).toHaveBeenCalled()
})
})
describe('nested-call skip', () => {
it('leaves nested composite results complete and spillable only through their outer call', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })

View File

@@ -439,7 +439,7 @@ describe('in-process structured output', () => {
it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
], {
toolMode: 'code',
codeRun: async (request) => {
@@ -465,7 +465,7 @@ describe('in-process structured output', () => {
it('discards a nested capture when the enclosing run_code execution fails', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")' }),
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")', description: 'Capture then fail the program' }),
textResponse('outer code failed'),
], {
toolMode: 'code',
@@ -494,7 +494,7 @@ describe('in-process structured output', () => {
it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
textResponse('outer code was blocked'),
], {
toolMode: 'code',

View File

@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"

Some files were not shown because too many files have changed in this diff Show More