Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # docs/cordis-catalog/services.md # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # packages/core/tools/README.i18n.yaml # packages/core/tools/src/code-mode.ts
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -126,6 +126,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
|
||||
@@ -211,6 +226,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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session 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 scope;key 空间在运行时开放);其渲染点逐行通过 `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']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />,
|
||||
}
|
||||
|
||||
|
||||
@@ -86,3 +86,15 @@ button.leading {
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program: monospace on the
|
||||
markdown code-block fill so the program reads as code, not prose. */
|
||||
.root[data-variant='code'] .body {
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 6px 8px;
|
||||
margin-left: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// @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', 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!)
|
||||
expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: 0e633876e3253182b12408df067b201c1b42a9f6
|
||||
README.zh.md: 387a85a856d2e23141053e1b228b4d6519adadd3
|
||||
README.md: 508057ac23fe9d8acf199fff7ace7f93eeee0b07
|
||||
README.zh.md: 3c83a2d6a1d38a564ae25300cc39f367f4f9ff0c
|
||||
|
||||
@@ -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 the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path); `deriveMessages()` does not surface that event or persist 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.
|
||||
- **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:
|
||||
|
||||
@@ -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>`,并附带完整的模型可见 `content`/`isError` 结果(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用);`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:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -11,23 +12,39 @@ import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-
|
||||
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 { 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 — and the sub-call's
|
||||
* complete model-facing outcome in `tool/result`'s own vocabulary
|
||||
* 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.
|
||||
* 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; content: ContentBlock[] }
|
||||
}
|
||||
@@ -179,9 +196,11 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
* 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 maxParallel - the run's overlap cap for parallel-classified
|
||||
* sub-calls (the registry passes its validated `maxParallelSubCalls`).
|
||||
* @returns the registry-ready definition.
|
||||
*/
|
||||
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
|
||||
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime, maxParallel: number): ToolDefinition {
|
||||
return defineTool({
|
||||
name: RUN_CODE_NAME,
|
||||
description:
|
||||
@@ -229,19 +248,115 @@ 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>>()
|
||||
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()
|
||||
}
|
||||
|
||||
// Read through a call, not a bare property: the abort state genuinely
|
||||
@@ -254,42 +369,91 @@ 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 => {
|
||||
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,
|
||||
// The registry deep-froze this projection at result finalization;
|
||||
// append snapshots it again, so the log copy stays detached.
|
||||
content: result.content,
|
||||
})
|
||||
resolve(result.isError
|
||||
? { isError: true, message: result.error.message }
|
||||
: { isError: false, value: result.value })
|
||||
}
|
||||
// Like the context forwarding above, cross-boundary facts travel on
|
||||
// the nested result and the composite forwards them: only a
|
||||
// successful nested result can carry the terminal marker
|
||||
// (ToolExecutionFailure types it never), so a policy-converted
|
||||
// failure cannot stop the turn through a recovering program.
|
||||
if (result.concludesTurn) exec.concludeTurn()
|
||||
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,
|
||||
// The registry deep-froze this projection at result finalization;
|
||||
// append snapshots it again, so the log copy stays detached.
|
||||
content: result.content,
|
||||
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)
|
||||
}
|
||||
// Like the context forwarding above, cross-boundary facts travel
|
||||
// on the nested result and the composite forwards them: only a
|
||||
// successful nested result can carry the terminal marker
|
||||
// (ToolExecutionFailure types it never), so a policy-converted
|
||||
// failure cannot stop the turn through a recovering program.
|
||||
if (result.concludesTurn) exec.concludeTurn()
|
||||
settle(result)
|
||||
},
|
||||
})
|
||||
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
|
||||
@@ -332,10 +496,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) {
|
||||
|
||||
@@ -546,6 +546,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -639,6 +647,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.
|
||||
@@ -648,6 +665,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. */
|
||||
@@ -686,7 +704,7 @@ export class ToolRegistry extends Service {
|
||||
// the filterable global/scoped capability layers.
|
||||
this.codeTransport = this.mode === 'native'
|
||||
? undefined
|
||||
: createRunCodeTool(this, () => this.requireCodeRuntime())
|
||||
: createRunCodeTool(this, () => this.requireCodeRuntime(), resolveMaxParallelSubCalls(config.maxParallelSubCalls))
|
||||
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
||||
if (this.mode !== 'native') {
|
||||
ctx.systemPrompt.section({
|
||||
|
||||
@@ -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:`
|
||||
|
||||
@@ -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 ?? {})
|
||||
@@ -358,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' })
|
||||
@@ -515,6 +841,37 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' })
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -1083,6 +1440,20 @@ describe('the run_code dispatch bridge', () => {
|
||||
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, {})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { request } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts'
|
||||
import { startMockLlmServer } from '../src/index.ts'
|
||||
|
||||
@@ -169,21 +169,23 @@ describe('mock LLM server wire behaviors', () => {
|
||||
['partial_disconnect', 100] as const,
|
||||
])('records a client that closes during %s', async (behavior, delayMs) => {
|
||||
const events: MockLlmServerEvent[] = []
|
||||
const result = Promise.withResolvers<Extract<MockLlmServerEvent, { type: 'result' }>>()
|
||||
const server = await start([behavior], {
|
||||
chunkDelayMs: delayMs,
|
||||
disconnectDelayMs: delayMs,
|
||||
chunkSize: 1,
|
||||
onEvent: (event) => { events.push(event) },
|
||||
onEvent: (event) => {
|
||||
events.push(event)
|
||||
if (event.type === 'result') result.resolve(event)
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const response = await chat(server, { signal: controller.signal })
|
||||
controller.abort()
|
||||
await expect(response.text()).rejects.toThrow()
|
||||
// The server observes the socket close asynchronously; a fixed sleep
|
||||
// raced slow runners, so poll until the outcome lands.
|
||||
await vi.waitFor(() => {
|
||||
expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' })
|
||||
})
|
||||
await result.promise
|
||||
|
||||
expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' })
|
||||
expect(events.filter(event => event.type === 'result')).toEqual([
|
||||
expect.objectContaining({ behavior, outcome: 'client_closed' }),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user