fix(client): consume typed business session events

This commit is contained in:
imccyu
2026-08-09 18:42:25 +08:00
parent 3d70889a8d
commit 10464d155d
47 changed files with 405 additions and 359 deletions

View File

@@ -32,6 +32,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
@@ -41,6 +42,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"zustand": "~4.4.7"

View File

@@ -139,7 +139,11 @@ export class ConversationLocationIndex {
return this.timeline
}
/** Replace all Definition-owned Location values while preserving reader identities. */
/**
* Replace all Definition-owned Location values while preserving reader identities.
* @param entries - complete current set of Definition-owned Location values.
* @returns whether any published Location data changed.
*/
replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean {
const turns = new Map<number, Map<string, OwnedLocationData>>()
const steps = new Map<string, Map<string, OwnedLocationData>>()
@@ -165,7 +169,11 @@ export class ConversationLocationIndex {
return changed
}
/** Apply changed Context publications without rebuilding Turn/Step membership. */
/**
* Apply changed Context publications without rebuilding Turn/Step membership.
* @param changes - incremental removals and replacements from published Contexts.
* @returns whether any published Location data changed.
*/
applyData(changes: readonly ConversationLocationDataChange[]): boolean {
let changed = false
for (const change of changes) {

View File

@@ -5,6 +5,9 @@
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-compact/types'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type {} from '@deepseek-ai/dsh-tools/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
@@ -109,48 +112,6 @@ export function inspectRequests(
}
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number | null }
}
interface CompactionSummaryEvent {
type: 'compact/summary'
seq: number
time: number
data: {
summary: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: unknown
}
}
interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number | null; error?: string }
}
function requestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
@@ -205,10 +166,8 @@ function deriveCallSchemas(
capture(String(event.data.callId), event.data.name)
continue
}
const type = event.type as string
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
const data = event.data as unknown as { subCallId: string; name: string }
capture(data.subCallId, data.name)
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
capture(String(event.data.subCallId), event.data.name)
}
}
return calls
@@ -351,14 +310,14 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
if (activeStep === key) activeStep = undefined
continue
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
if (sourceEvent.type === 'llm/retry') {
const data = sourceEvent.data
updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), {
status: 'error',
error: displayFailureMessage(event.data.failure),
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
error: displayFailureMessage(data.failure),
retry: data.retry,
...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {},
retryDelayMs: data.delayMs,
})
continue
}
@@ -374,8 +333,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
continue
}
const type = sourceEvent.type as string
if (type === 'session/end-seed' && activeCompaction !== undefined) {
if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
@@ -384,37 +342,36 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
activeCompaction = undefined
continue
}
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
if (sourceEvent.type === 'compact/start') {
activeCompaction = requests.length
requests.push({
purpose: 'compaction',
startSeq: event.seq,
turn: event.data.turn,
startSeq: sourceEvent.seq,
turn: sourceEvent.data.turn,
step: 0,
startedAt: event.time,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
})
continue
}
if (type === 'compact/summary' && activeCompaction !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) {
const data = sourceEvent.data
updateCompaction(activeCompaction, {
resultSeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
resultSeq: sourceEvent.seq,
summary: data.summary,
...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }),
provenance: {
provider: event.data.provider,
model: event.data.model,
provider: data.provider,
model: data.model,
},
requestConfig: {
provider: event.data.provider,
model: event.data.model,
provider: data.provider,
model: data.model,
purpose: 'compaction',
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }),
},
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
...(data.usage === undefined ? {} : { usage: data.usage }),
})
continue
}
@@ -426,12 +383,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (type !== 'compact/end' || activeCompaction === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue
updateCompaction(activeCompaction, {
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
completedAt: sourceEvent.time,
status: sourceEvent.data.error === undefined ? 'complete' : 'error',
...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }),
})
activeCompaction = undefined
}

View File

@@ -1,6 +1,7 @@
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-agent/types'
type InboxTarget = 'next-turn' | 'next-step'
@@ -45,8 +46,8 @@ export class SteeringHistory {
* @returns true only for a user-origin message previously claimed from `next-step`.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'agent/inbox/spliced') {
this.applySplice(event.data as unknown as InboxSplice)
if (event.type === 'agent/inbox/spliced') {
this.applySplice(event.data)
return false
}
if (event.type !== 'user/message') return false

View File

@@ -1,5 +1,5 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-tools/types'
import type {
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from './conversation.ts'
@@ -55,13 +55,8 @@ export class ToolCallTree {
* @returns Whether the event was consumed as a child-call lifecycle event.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
if (event.type === 'tool/code-dispatch-start') {
const data = event.data
const running: RunningToolCall = {
callId: data.subCallId,
name: data.name,
@@ -78,15 +73,8 @@ export class ToolCallTree {
this.revision++
return true
}
if ((event.type as string) !== 'tool/code-dispatch') return false
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
if (event.type !== 'tool/code-dispatch') return false
const data = event.data
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true

View File

@@ -26,6 +26,12 @@
{
"path": "../../interaction/commands"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../compact/compact"
},

View File

@@ -39,22 +39,28 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
@@ -62,9 +68,11 @@
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -6,6 +6,7 @@ import type {
import {
emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type { AssistantChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
@@ -197,7 +198,7 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
}
continue
}
if ((match.event.type as string) === 'llm/retry' && state !== undefined) {
if (match.event.type === 'llm/retry' && state !== undefined) {
state = resetForRetry(state)
}
}
@@ -247,9 +248,8 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|| (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
if ((event.type as string) === 'llm/retry') {
const data = event.data as unknown as { turn: number; step: number }
return { id: `${data.turn}:${data.step}`, role: 'update' }
if (event.type === 'llm/retry') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
return null
},
@@ -268,7 +268,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
usage: match.event.data.usage,
}
}
if ((match.event.type as string) === 'llm/retry') {
if (match.event.type === 'llm/retry') {
return resetForRetry(context.state)
}
return context.state

View File

@@ -5,6 +5,8 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type {} from '@deepseek-ai/dsh-compact/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import type { ManualCompactionChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
@@ -32,21 +34,9 @@ interface CompactionEvidence {
readonly checkpoint?: ConversationMatch
}
interface CommandRunData {
readonly commandId: CommandId
readonly name: string
readonly args?: string
}
interface CommandDoneData {
readonly commandId: CommandId
readonly kind: 'success' | 'error'
readonly text?: string
readonly sourceEventSeq?: number
}
function commandFromRun(match: ConversationMatch): CommandNode {
const data = match.event.data as unknown as CommandRunData
if (match.event.type !== 'command/run') throw new Error('command start requires command/run')
const data = match.event.data
return {
kind: 'command',
seq: match.event.seq,
@@ -59,10 +49,12 @@ function commandFromRun(match: ConversationMatch): CommandNode {
}
function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode {
const data = match.event.data as unknown as CommandDoneData
if (match.event.type !== 'command/done') throw new Error('command update requires command/done')
const data = match.event.data
const sourceEventSeq = data.kind === 'success'
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
? data.sourceEventSeq as number
&& data.sourceEventSeq !== undefined
&& Number.isSafeInteger(data.sourceEventSeq) && data.sourceEventSeq >= 0
? data.sourceEventSeq
: undefined
return {
kind: 'command',
@@ -112,28 +104,21 @@ function compactSummary(match: ConversationMatch | undefined, checkpoint: Conver
let summary: string | null = null
let shadowedItemCount: number | null = null
let shadowedTokenCount: number | null = null
if (match !== undefined) {
const data = match.event.data as unknown as {
summary?: unknown
shadowedSeqs?: unknown
shadowedTokenCount?: unknown
}
if (match?.event.type === 'compact/summary') {
const data = match.event.data
if (Array.isArray(data.summary)) {
const text = data.summary
.map((block: unknown) => {
const value = block as { type?: unknown; text?: unknown }
return value.type === 'text' && typeof value.text === 'string' ? value.text : ''
})
.map(block => block.type === 'text' ? block.text : '')
.join('')
summary = text.trim() === '' ? null : text
}
shadowedItemCount = Array.isArray(data.shadowedSeqs)
&& data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && (seq as number) >= 0)
&& data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0)
? data.shadowedSeqs.length
: null
shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount)
&& (data.shadowedTokenCount as number) >= 0
? data.shadowedTokenCount as number
&& data.shadowedTokenCount >= 0
? data.shadowedTokenCount
: null
}
return {
@@ -148,9 +133,9 @@ function compactSummary(match: ConversationMatch | undefined, checkpoint: Conver
}
function fallbackState(context: ConversationNodeContext<CommandState>): CommandState | undefined {
const done = context.matches.find(match => (match.event.type as string) === 'command/done')
const done = context.matches.find(match => match.event.type === 'command/done')
const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined)
const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary')
const summary = context.matches.find(match => match.event.type === 'compact/summary')
if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) }
const source = compactSource(checkpoint.event)
if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) }
@@ -182,7 +167,7 @@ export function updateCompactionState<State extends CompactionEvidence>(
state: State,
match: ConversationMatch,
): State {
if ((match.event.type as string) === 'compact/summary') return { ...state, summary: match }
if (match.event.type === 'compact/summary') return { ...state, summary: match }
if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match }
return state
}
@@ -191,27 +176,28 @@ export function updateCompactionState<State extends CompactionEvidence>(
export const commandDefinition: ConversationNodeDefinition<CommandState> = {
kind: 'command',
match: (event) => {
if ((event.type as string) === 'command/run') {
return { id: String((event.data as unknown as CommandRunData).commandId), role: 'start' }
if (event.type === 'command/run') {
return { id: String(event.data.commandId), role: 'start' }
}
if ((event.type as string) === 'command/done') {
return { id: String((event.data as unknown as CommandDoneData).commandId), role: 'update' }
if (event.type === 'command/done') {
return { id: String(event.data.commandId), role: 'update' }
}
const checkpoint = compactSource(event)
if (checkpoint?.sourceCommandId !== undefined) {
return { id: String(checkpoint.sourceCommandId), role: 'update' }
}
if ((event.type as string) === 'compact/start'
|| (event.type as string) === 'compact/summary'
|| (event.type as string) === 'compact/end') {
const data = event.data as unknown as { sourceCommandId?: CommandId }
if (data.sourceCommandId !== undefined) return { id: String(data.sourceCommandId), role: 'update' }
if (event.type === 'compact/start'
|| event.type === 'compact/summary'
|| event.type === 'compact/end') {
if (event.data.sourceCommandId !== undefined) {
return { id: String(event.data.sourceCommandId), role: 'update' }
}
}
return null
},
start: (_context, match) => ({ command: commandFromRun(match) }),
update: (context, match) => {
if ((match.event.type as string) === 'command/done') {
if (match.event.type === 'command/done') {
return { ...context.state, command: commandFromDone(match, context.state.command) }
}
return updateCompactionState(context.state, match)

View File

@@ -2,6 +2,7 @@ import type { Context } from 'cordis'
import type {
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-compact/types'
import { chatNode } from './common.ts'
import { compactSource, compactSummary, updateCompactionState } from './command.ts'
@@ -18,7 +19,7 @@ interface CompactionState {
}
function fallbackState(context: ConversationNodeContext<CompactionState>): CompactionState {
const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary')
const summary = context.matches.find(match => match.event.type === 'compact/summary')
const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined)
return {
...summary === undefined ? {} : { summary },
@@ -34,12 +35,11 @@ export const compactionDefinition: ConversationNodeDefinition<CompactionState> =
if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) {
return { id: checkpoint.compactionId, role: 'update' }
}
if ((event.type as string) === 'compact/start'
|| (event.type as string) === 'compact/summary'
|| (event.type as string) === 'compact/end') {
const data = event.data as unknown as { compactionId?: unknown; sourceCommandId?: unknown }
if (typeof data.compactionId !== 'string' || data.sourceCommandId !== undefined) return null
return { id: data.compactionId, role: (event.type as string) === 'compact/start' ? 'start' : 'update' }
if (event.type === 'compact/start'
|| event.type === 'compact/summary'
|| event.type === 'compact/end') {
if (event.data.sourceCommandId !== undefined) return null
return { id: String(event.data.compactionId), role: event.type === 'compact/start' ? 'start' : 'update' }
}
return null
},

View File

@@ -2,6 +2,7 @@ import type { Context } from 'cordis'
import type {
ConversationNodeDefinition, ConversationPreviousContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-agent/types'
type InboxTarget = 'next-turn' | 'next-step'
@@ -41,14 +42,14 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition<InboxS
const kind = `inbox-${target}`
return {
kind,
match: event => (event.type as string) === 'agent/inbox/spliced'
&& (event.data as unknown as { target?: unknown }).target === target
match: event => event.type === 'agent/inbox/spliced'
&& event.data.target === target
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => applySplice(
reader.previous<InboxState>(kind),
match.event.data as unknown as InboxSplice,
),
start: (_context, match, reader) => {
if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`)
return applySplice(reader.previous<InboxState>(kind), match.event.data)
},
update: context => context.state,
publication: () => 'none',
buildViewNode: () => null,

View File

@@ -2,6 +2,7 @@ import type { Context } from 'cordis'
import type {
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type { RetryChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
@@ -12,11 +13,6 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
}
}
type WithoutRetryProjection<Node> = Node extends unknown
? Omit<Node, 'kind' | 'seq' | 'time' | 'retryState'>
: never
type RetryEventData = WithoutRetryProjection<ModelRetryNode>
/** Accumulated retry attempts sharing one producer-owned RetryId. */
export interface RetryState {
readonly turn: number
@@ -24,31 +20,14 @@ export interface RetryState {
readonly attempts: readonly ModelRetryNode[]
}
function retryData(value: unknown): RetryEventData | undefined {
if (value === null || typeof value !== 'object') return undefined
const data = value as Record<string, unknown>
if (typeof data.retryId !== 'string' || data.retryId === ''
|| !Number.isSafeInteger(data.turn) || (data.turn as number) < 0
|| !Number.isSafeInteger(data.step) || (data.step as number) < 0
|| !Number.isSafeInteger(data.retry) || (data.retry as number) <= 0
|| typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0
|| typeof data.provider !== 'string' || typeof data.policyKey !== 'string'
|| (data.mode !== 'normal' && data.mode !== 'always')
|| data.failure === null || typeof data.failure !== 'object') return undefined
if (data.mode === 'normal' && (!Number.isSafeInteger(data.maxRetries) || (data.maxRetries as number) <= 0)) {
return undefined
}
return data as unknown as RetryEventData
}
function scheduledNode(event: { seq: number; time: number; data: unknown }): ModelRetryNode | undefined {
const data = retryData(event.data)
return data === undefined ? undefined : {
function scheduledNode(match: Parameters<ConversationNodeDefinition['start']>[1]): ModelRetryNode | undefined {
if (match.event.type !== 'llm/retry') return undefined
return {
kind: 'model-retry',
seq: event.seq,
time: event.time,
seq: match.event.seq,
time: match.event.time,
retryState: 'scheduled',
...data,
...match.event.data,
}
}
@@ -61,33 +40,30 @@ function isClosed(location: ConversationLocation): boolean {
export const retryDefinition: ConversationNodeDefinition<RetryState> = {
kind: 'model-retry',
match: (event) => {
if ((event.type as string) === 'llm/retry') {
const data = retryData(event.data)
if (data === undefined) return null
return { id: String(data.retryId), role: data.retry === 1 ? 'start' : 'update' }
if (event.type === 'llm/retry') {
return { id: String(event.data.retryId), role: event.data.retry === 1 ? 'start' : 'update' }
}
if ((event.type as string) === 'llm/retry-started') {
const data = event.data as unknown as { retryId?: unknown }
return typeof data.retryId === 'string' ? { id: data.retryId, role: 'update' } : null
if (event.type === 'llm/retry-started') {
return { id: String(event.data.retryId), role: 'update' }
}
return null
},
start: (_context, match) => {
const node = scheduledNode(match.event)
const node = scheduledNode(match)
if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event')
return { turn: node.turn, step: node.step, attempts: [node] }
},
update: (context, match) => {
if ((match.event.type as string) === 'llm/retry') {
const node = scheduledNode(match.event)
if (match.event.type === 'llm/retry') {
const node = scheduledNode(match)
return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] }
}
if ((match.event.type as string) !== 'llm/retry-started') return context.state
const data = match.event.data as unknown as { retry: number }
if (match.event.type !== 'llm/retry-started') return context.state
const retry = match.event.data.retry
return {
...context.state,
attempts: context.state.attempts.map(attempt =>
attempt.retry === data.retry ? { ...attempt, retryState: 'started' } : attempt),
attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt),
}
},
buildViewNode: (context, target) => {

View File

@@ -4,6 +4,7 @@ import type {
RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-tools/types'
import type { ToolChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
@@ -141,27 +142,30 @@ function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
}
function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
const data = match.event.data as unknown as DispatchData
const siblings = state.children.get(data.parentCallId) ?? []
const index = siblings.findIndex(candidate => candidate.callId === data.subCallId)
if ((match.event.type as string) === 'tool/code-dispatch-start') {
if (index >= 0 || !acceptsEdge(state, data.parentCallId, data.subCallId)) return state
const event = match.event
if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state
const data = event.data
const parentCallId = String(data.parentCallId)
const subCallId = String(data.subCallId)
const siblings = state.children.get(parentCallId) ?? []
const index = siblings.findIndex(candidate => candidate.callId === subCallId)
if (event.type === 'tool/code-dispatch-start') {
if (index >= 0 || !acceptsEdge(state, parentCallId, subCallId)) return state
const children = new Map(state.children)
children.set(data.parentCallId, [...siblings, childCall(match, data)])
children.set(parentCallId, [...siblings, childCall(match, data)])
const parents = new Map(state.parents)
parents.set(data.subCallId, data.parentCallId)
parents.set(subCallId, parentCallId)
return { ...state, children, parents }
}
if ((match.event.type as string) !== 'tool/code-dispatch') return state
if (index < 0 && !acceptsEdge(state, data.parentCallId, data.subCallId)) return state
if (index < 0 && !acceptsEdge(state, parentCallId, subCallId)) return state
const previous = index < 0 ? undefined : siblings[index]
const settled = childResult(match, data, previous)
const children = new Map(state.children)
children.set(data.parentCallId, index < 0
children.set(parentCallId, index < 0
? [...siblings, settled]
: siblings.map((child, at) => at === index ? settled : child))
const parents = new Map(state.parents)
if (index < 0) parents.set(data.subCallId, data.parentCallId)
if (index < 0) parents.set(subCallId, parentCallId)
return { ...state, children, parents }
}
@@ -236,9 +240,8 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
return { id: String(event.data.message.source.callId), role: 'update' }
}
if ((event.type as string) === 'tool/code-dispatch-start' || (event.type as string) === 'tool/code-dispatch') {
const data = event.data as unknown as { rootCallId: string }
return { id: data.rootCallId, role: 'update' }
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
return { id: String(event.data.rootCallId), role: 'update' }
}
return null
},

View File

@@ -3,6 +3,7 @@ import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import { chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
@@ -30,9 +31,9 @@ function lastStep(context: ConversationNodeContext<TurnErrorState>): number {
}
function retryTurn(event: Parameters<ConversationNodeDefinition['match']>[0]): number | undefined {
if ((event.type as string) !== 'llm/retry' && (event.type as string) !== 'llm/retry-started') return undefined
const turn = (event.data as unknown as { turn?: unknown }).turn
return Number.isSafeInteger(turn) && (turn as number) >= 0 ? turn as number : undefined
return event.type === 'llm/retry' || event.type === 'llm/retry-started'
? event.data.turn
: undefined
}
function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined {

View File

@@ -3,6 +3,7 @@ import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type {
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
} from '../contract/chat-nodes.ts'
@@ -58,9 +59,7 @@ function turnCoordinates(event: Parameters<ConversationNodeDefinition['match']>[
|| event.type === 'step/end') {
return { turn: event.data.turn, step: event.data.step }
}
if ((event.type as string) === 'llm/retry') {
return event.data as unknown as { turn: number; step: number }
}
if (event.type === 'llm/retry') return { turn: event.data.turn, step: event.data.step }
return undefined
}
@@ -90,7 +89,7 @@ function closingAnchor(context: ConversationNodeContext<TurnTailState>): number
}
continue
}
if ((event.type as string) === 'llm/retry') {
if (event.type === 'llm/retry') {
steps.set(coordinates.step, { streamedText: false, finalized: false })
continue
}
@@ -130,7 +129,7 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
const candidate = event.type === 'tool/call'
|| (event.type === 'tool/result' && isAppendSurfaceEvent(event))
|| (event.type === 'turn/end' && event.data.reason.kind === 'error')
|| (event.type as string) === 'llm/retry'
|| event.type === 'llm/retry'
? event.seq
: undefined
if (candidate !== undefined && (latestTranscriptSeq === undefined || candidate > latestTranscriptSeq)) {

View File

@@ -62,7 +62,9 @@ describe('apply wiring', () => {
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.node')).toEqual({ kind: 'keyed', scope: 'session' })
const nodeSlot = b.slots.spec('conversation.chat.node')
expect(nodeSlot).toMatchObject({ kind: 'keyed', scope: 'session' })
expect(nodeSlot?.inject?.hooks?.turnData).toBeTypeOf('function')
await b.runtime.dispose()
})

View File

@@ -644,6 +644,28 @@ describe('built-in conversation node Definitions', () => {
})
})
it('renders a historical compaction when its start remains outside the loaded window', () => {
const value = assembler([
at(10, 'compact/summary', {
compactionId: 'compact-windowed',
summary: [{ type: 'text', text: 'loaded summary' }],
shadowedSeqs: [1, 2, 3],
shadowedTokenCount: 42,
}),
at(11, 'user/message', {
...textMessage('checkpoint-windowed', 'checkpoint'),
source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
}, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
], true)
expect(node(snapshot(value), 'compaction')?.data).toMatchObject({
summary: 'loaded summary',
summaryEventSeq: 10,
shadowedItemCount: 3,
shadowedTokenCount: 42,
})
})
it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => {
const value = assembler([
at(5, 'llm/retry', {

View File

@@ -23,12 +23,24 @@
{
"path": "../runtime"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../../plan/plan-mode"
},