Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/headless-agent/tests/todo-write.e2e.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/todo/tool-todo/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-07-28 20:41:55 +08:00
391 changed files with 6129 additions and 3065 deletions

View File

@@ -20,6 +20,7 @@ import type { SessionSurface } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
export * from './types.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
@@ -145,6 +146,33 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
return deepFreeze(record as unknown as SessionHeader)
}
/**
* Detach one event while preserving deep immutability for its identified message.
* @param event - event imported across a query or persistence boundary.
* @returns a detached event snapshot with a validated, deeply frozen message.
*/
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
const snapshot = structuredClone(event)
assertMessageEventShape(
snapshot,
`session event at seq ${snapshot.seq}`,
)
switch (snapshot.type) {
case 'user/message':
deepFreeze(snapshot.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(snapshot.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return snapshot
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
@@ -165,13 +193,14 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
assertCurrentTurnEndShape(event, index)
}
/** Reject pre-provider request headers and assistant messages at the seed/load boundary. */
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
function assertCurrentLlmShape(event: Record<string, unknown>, index: number): void {
const data = event['data']
if (typeof data !== 'object' || data === null) return
const record = data as Record<string, unknown>
const record = typeof data === 'object' && data !== null
? data as Record<string, unknown>
: undefined
if (event['type'] === 'request/header') {
const header = record['header']
const header = record?.['header']
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
@@ -180,8 +209,63 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
}
}
if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) {
throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`)
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
assertMessageEventShape(event, `seed ${type} at index ${index}`)
}
/** Validate only the event-specific invariants needed to safely replay a message. */
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
const data = event['data']
const record = typeof data === 'object' && data !== null
? data as Record<string, unknown>
: undefined
const message = type === 'user/message' ? record : record?.['message']
if (typeof message !== 'object' || message === null
|| typeof (message as Record<string, unknown>)['id'] !== 'string'
|| (message as Record<string, unknown>)['id'] === '') {
throw new Error(`${subject} lacks an identified message`)
}
const messageRecord = message as Record<string, unknown>
const expectedRole = type === 'assistant/message' ? 'assistant' : 'user'
if (messageRecord['role'] !== expectedRole) {
throw new Error(`${subject} message must have role "${expectedRole}"`)
}
const source = messageRecord['source']
if (typeof source !== 'object' || source === null
|| typeof (source as Record<string, unknown>)['kind'] !== 'string'
|| (source as Record<string, unknown>)['kind'] === '') {
throw new Error(`${subject} message has invalid source`)
}
if (!Array.isArray(messageRecord['content'])) {
throw new Error(`${subject} message has invalid content`)
}
const sourceRecord = source as Record<string, unknown>
if (type === 'assistant/message') {
if (sourceRecord['kind'] !== 'model' || !hasProviderModel(sourceRecord)) {
throw new Error(`${subject} message must have model source`)
}
return
}
if (type !== 'tool/result') return
if (sourceRecord['kind'] !== 'tool'
|| typeof sourceRecord['callId'] !== 'string'
|| sourceRecord['callId'] === '') {
throw new Error(`${subject} message must have tool source`)
}
const content = messageRecord['content'] as unknown[]
const block = content[0]
if (content.length !== 1 || typeof block !== 'object' || block === null
|| (block as Record<string, unknown>)['type'] !== 'tool-result'
|| !Array.isArray((block as Record<string, unknown>)['content'])) {
throw new Error(`${subject} message must contain one tool-result block`)
}
if ((block as Record<string, unknown>)['toolCallId'] !== sourceRecord['callId']) {
throw new Error(`${subject} message has mismatched tool call ids`)
}
}
@@ -517,7 +601,7 @@ export class Session {
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
// usage) derives to null and must not enter the transcript.
if (msg) this.derived.push(deepFreeze(msg))
if (msg) this.derived.push(msg)
}
this.derivedNodes = nodes.length
return [...this.derived]
@@ -530,10 +614,9 @@ export class Session {
* The per-node pure function {@link deriveMessages} folds over the surface;
* an external reconstructor (or the dev invariant) folds the same function
* over a log prefix's surface to rebuild the exact messages any request was
* built from (the reconstructability Agent Note). The returned message wrapper is
* fresh; its content reuses the logged event's already deep-frozen durable
* data, so changing the wrapper cannot rewrite the log and changing content
* throws.
* built from (the reconstructability Agent Note). The returned message is
* the already frozen message nested in the event wrapper and shared by
* delivery, durable history, and model requests.
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
@@ -545,30 +628,28 @@ export class Session {
switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. The message's `source` and steering's `turn` are log-only. Do NOT
// verbatim. Steering's `turn` is log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by
// the event `meta` map and a dedicated renderer, keeping this projection a
// verbatim pass-through. See the deferred design note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
case 'user/message':
case 'user/message': {
return event.data
}
case 'steering/message': {
return { role: 'user', content: event.data.content }
return event.data.message
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.content.length === 0) return null
return { role: 'assistant', content: event.data.content, provenance: event.data.provenance }
if (event.data.message.content.length === 0) return null
return event.data.message
}
case 'tool/result': {
const { callId, content, isError } = event.data
return {
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
}
return event.data.message
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to

View File

@@ -134,11 +134,12 @@ function validateEvent(
break
}
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
const syntheticNotStarted = event.data.isError && event.data.error?.code === TOOL_NOT_STARTED
if (!trace.pendingCalls.has(event.data.callId) && !syntheticNotStarted) {
fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
const callId = event.data.message.source.callId
const syntheticNotStarted = event.data.message.content[0].isError === true && event.data.error?.code === TOOL_NOT_STARTED
if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) {
fail(`tool/result for ${callId} with no prior tool/call in this step`)
}
pendingCalls = { kind: 'delete', callId: event.data.callId }
pendingCalls = { kind: 'delete', callId }
break
}
case 'user/message':

View File

@@ -5,7 +5,8 @@
* @module @deepseek-ai/dsh-session/repair
*/
import type { CallId } from '@deepseek-ai/dsh-llm'
import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/** Recovery code for an assistant tool request that never reached a recorded call start. */
@@ -51,7 +52,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
case 'assistant/message':
// The assistant message carries the tool-call blocks; each is pending
// until a tool/result event with the same callId is logged.
for (const block of event.data.content) {
for (const block of event.data.message.content) {
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
}
break
@@ -65,7 +66,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
}
break
case 'tool/result':
pendingCalls.delete(event.data.callId)
pendingCalls.delete(event.data.message.source.callId)
break
// Other event types do not move the turn/step boundary cursor.
default:
@@ -89,6 +90,22 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
// and Map insertion order preserves their transcript order.
for (const [callId, { step, callSeq }] of pendingCalls) {
const started = callSeq !== undefined
const message: ToolResultMessage = freezeMessage({
id: MessageId(`interrupted-tool-result-${callId}-${seq}`),
role: 'user',
source: { kind: 'tool', callId },
content: [{
type: 'tool-result',
toolCallId: callId,
isError: true,
content: [{
type: 'text',
text: started
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}],
}],
})
closers.push({
type: 'tool/result',
seq: seq++,
@@ -96,14 +113,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
data: {
turn: openTurn,
step,
callId,
content: [{
type: 'text',
text: started
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}],
isError: true,
message,
error: started
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },

View File

@@ -224,8 +224,16 @@ function assertToolResultRewrite(
}
const originalRest = { ...original.data } as Record<string, unknown>
const replacementRest = { ...event.data } as Record<string, unknown>
delete originalRest['content']
delete replacementRest['content']
const originalResult = original.data.message.content[0]
const replacementResult = event.data.message.content[0]
originalRest['message'] = {
...original.data.message,
content: [{ ...originalResult, content: null }],
}
replacementRest['message'] = {
...event.data.message,
content: [{ ...replacementResult, content: null }],
}
if (!isDeepEqualJson(originalRest, replacementRest)) {
throw new Error('tool/result surface replacement may change only content')
}

View File

@@ -1,5 +1,16 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type {
AssistantMessage,
CallId,
LlmCallConfig,
LlmFailure,
MessageSource,
StreamChunk,
TokenUsage,
ToolResultMessage,
ToolSchema,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import type { JsonValue } from './json.ts'
/** Identifies one session in the store (and its persistence artifacts). */
@@ -166,20 +177,6 @@ export interface EpochHeader {
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/**
* Shared payload for user, injected-context, and steering messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content;
* they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type.
*/
export interface UserMessageData {
/** Exact model-facing blocks. */
content: ContentBlock[]
/** Producer provenance. */
source: MessageSource
}
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
@@ -210,7 +207,7 @@ export interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
*/
'user/message': UserMessageData
'user/message': UserMessage
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -219,7 +216,7 @@ export interface SessionEventMap {
* the model output and its accounting travel together (there is no separate
* usage record). `usage` is absent when the adapter reported none.
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage }
'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
@@ -240,14 +237,12 @@ export interface SessionEventMap {
'tool/result': {
turn: number
step: number
callId: CallId
content: ContentBlock[]
isError: boolean
message: ToolResultMessage
error?: { name: string; code: string }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': UserMessageData & { turn: number }
'steering/message': { turn: number; message: UserMessage }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**