refactor(session): stream raw tool events

This commit is contained in:
imccyu committed 2026-08-24 10:24:17 +08:00
1 parent 64c9e4a22c
commit a42c0b523a
20 files changed
+300 -767

No files matched your search

+5 -7
View File
@@ -258,13 +258,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
// The seed carries a session/title event: the title unit is host-plane, so
// it folds the detached log and serves the value with nothing composed.
expect(typeof projections?.values.title).toBe('string')
// `todos` IS here, as its empty fold (null). Its unit is registered by
// `tool-todo` inside the default preset's STANDING mount, which the read
// itself ensures — deterministically, not because some unrelated session
// happens to be composed. A present-but-null key is what keeps the
// client's "omitted key = capability absent → clear the row" rule from
// wiping preset-owned projections on cold reads.
expect(projections?.values).toHaveProperty('todos', null)
// `todos` is absent because its unit belongs to the agent preset and this
// directly seeded session never composed that preset. History computes
// the baseline through the standard projection registry without mounting
// an Agent composition as a read side effect.
expect(projections?.values).not.toHaveProperty('todos')
// The session-stats unit is a shipped web-app bundle row: whole-log
// turn/step counts ride the same tail block (the stats strip's source).
const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined
+1 -1
View File
@@ -52,7 +52,7 @@ export type {
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView,
SubagentAddress, SubagentCatalog,
} from '@deepseek-ai/dsh-client-connection/client'
export type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
+1 -4
View File
@@ -96,7 +96,6 @@
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
@@ -106,8 +105,7 @@
"@deepseek-ai/dsh-jobs": { "optional": true },
"@deepseek-ai/dsh-session-persistence": { "optional": true },
"@deepseek-ai/dsh-session-projection": { "optional": true },
"@deepseek-ai/dsh-session-projection-cache": { "optional": true },
"@deepseek-ai/dsh-tools": { "optional": true }
"@deepseek-ai/dsh-session-projection-cache": { "optional": true }
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -131,7 +129,6 @@
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^",
@@ -22,7 +22,6 @@ import type {
SessionQueuedItem,
SessionRequestId,
SessionError,
SessionToolView,
} from '../../types.ts'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import { transportResult } from '../contract/result.ts'
@@ -74,9 +73,6 @@ export interface SessionOptions {
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private eventWindow: SessionEvent[] = []
/** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view).
* Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */
private views: (SessionToolView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -405,7 +401,6 @@ export class Session implements SessionFace {
this.openState = 'cold'
this.openError = null
this.eventWindow = []
this.views = []
this.baseSeq = 0
this.notifier.markDirty()
await this.open()
@@ -582,7 +577,6 @@ export class Session implements SessionFace {
/** Replace the complete contiguous window and apply page-owned projection metadata. */
private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.eventWindow = entries.map(entry => entry.event as SessionEvent)
this.views = entries.map(entry => entry.view)
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
@@ -594,7 +588,6 @@ export class Session implements SessionFace {
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
this.views = [...entries.map(entry => entry.view), ...this.views]
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
@@ -604,7 +597,6 @@ export class Session implements SessionFace {
private appendLive(entry: SessionEventEntry): boolean {
const event = entry.event as SessionEvent
this.eventWindow.push(event)
this.views.push(entry.view)
const awaitingFirstTurn = this.firstPromptPendingTurn
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
+11 -138
View File
@@ -1,14 +1,10 @@
/** Cold Session history pagination and live-event source. */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
@@ -19,34 +15,21 @@ import type {
SessionPageRequest,
SessionProjectionsBlock,
SessionProjectionValues,
SessionToolCallView,
SessionToolView,
SessionWireEvent,
} from './types.ts'
const DEFAULT_MAX_MESSAGES = 50
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
interface ToolCallData {
readonly callId: string
readonly name: string
readonly arguments: string
}
type SessionSource =
| { readonly kind: 'attached'; readonly session: Session }
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
interface BufferedEvent {
readonly session: Session
readonly event: SessionEvent
}
/** Implements cold-safe history operations delegated by the Session Controller. */
export class SessionHistoryController {
private readonly closeFollowers = new Set<() => void>()
/** @param ctx - Host context carrying Session, persistence, presenter, and projection services. */
/** @param ctx - Host context carrying Session, persistence, and projection services. */
constructor(private readonly ctx: Context) {
ctx.effect(() => () => {
for (const close of this.closeFollowers) close()
@@ -57,7 +40,7 @@ export class SessionHistoryController {
/**
* Read one message-aligned history page without activating an Agent.
* @param request - durable address and backwards-page cursor.
* @param signal - caller cancellation for persistence and preset reads.
* @param signal - caller cancellation for persistence reads.
* @returns a contiguous event page and a projection baseline on tail reads.
*/
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
@@ -77,11 +60,8 @@ export class SessionHistoryController {
if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
}
const scope = await this.presenterScopeFor(addressId(request.address), source, events)
signal.throwIfAborted()
const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
const argsFor = (callId: string) => backscanArgs(page.events, callId)
const entries = page.events.map(event => entryFor(this.ctx, event, argsFor, scope))
const entries = page.events.map(entryFor)
const projections = request.beforeSeq === undefined
? this.projectionsFor(request.address, source, events)
: undefined
@@ -102,12 +82,7 @@ export class SessionHistoryController {
validateFollowRequest(request)
const { address, afterSeq } = request
const target = addressId(address)
const buffered: BufferedEvent[] = []
const openCalls = new Map<string, { readonly name: string; readonly args: unknown }>()
let fallbackEvents: readonly SessionEvent[] = []
const argsFor = (callId: string): { readonly name: string; readonly args: unknown } | undefined => (
openCalls.get(callId) ?? backscanArgs(fallbackEvents, callId)
)
const buffered: SessionEvent[] = []
let wake: (() => void) | undefined
const notify = (): void => {
const resume = wake
@@ -122,7 +97,7 @@ export class SessionHistoryController {
this.closeFollowers.add(close)
const disposeEvent = this.ctx.on('session/event', (session, event) => {
if (session.id !== target) return
buffered.push({ session, event })
buffered.push(event)
notify()
}, { global: true })
const disposeCreated = this.ctx.on('session/created', (session) => {
@@ -130,7 +105,7 @@ export class SessionHistoryController {
// Session construction appends session/end-seed before attachment, so the
// marker has no session/event notification. Earlier session/created listeners
// may publish later setup events first; this suffix must precede those notifications.
const suffix = session.events.slice(session.firstLiveSeq).map(event => ({ session, event }))
const suffix = session.events.slice(session.firstLiveSeq)
buffered.unshift(...suffix)
notify()
}, { global: true })
@@ -139,8 +114,6 @@ export class SessionHistoryController {
try {
const source = await this.sourceFor(address, signal)
const events = [...sourceEvents(source)]
fallbackEvents = events
const scope = await this.presenterScopeFor(target, source, events)
signal.throwIfAborted()
const cursor = events.at(-1)?.seq ?? -1
if (afterSeq !== undefined && afterSeq > cursor) {
@@ -155,7 +128,7 @@ export class SessionHistoryController {
reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
yield { type: 'event', ...entryFor(this.ctx, event, argsFor, scope) }
yield { type: 'event', ...entryFor(event) }
}
}
while (!follower.closed && !signal.aborted) {
@@ -164,26 +137,12 @@ export class SessionHistoryController {
await new Promise<void>((resolve) => { wake = resolve })
continue
}
if (item.event.seq < nextSeq) continue
if (item.event.seq !== nextSeq) {
if (item.seq < nextSeq) continue
if (item.seq !== nextSeq) {
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
if (item.event.type === 'tool/call') {
const data = item.event.data as ToolCallData
const call = parseToolCall(data)
/* v8 ignore next -- malformed durable tool arguments intentionally skip the live presentation cache. */
if (call !== undefined) openCalls.set(data.callId, call)
} else if (item.event.type === 'turn/end') {
openCalls.clear()
}
if (item.event.type === 'tool/result'
&& !openCalls.has(item.event.data.message.source.callId)) {
fallbackEvents = item.session.events
}
const liveScope: Agent | undefined = this.ctx.get('agents')?.get(target)
const entry = entryFor(this.ctx, item.event, argsFor, liveScope ?? scope)
yield { type: 'event', ...entry }
yield { type: 'event', ...entryFor(item) }
}
} finally {
this.closeFollowers.delete(close)
@@ -214,25 +173,6 @@ export class SessionHistoryController {
return { kind: 'detached', header: inspected.meta, events: inspected.events }
}
private async presenterScopeFor(
sessionId: SessionId,
source: SessionSource,
events: readonly SessionEvent[],
): Promise<ScopeKey | undefined> {
const live = this.ctx.get('agents')?.get(sessionId)
if (live !== undefined) return live
const presets = this.ctx.get('agentPresets')
if (presets === undefined) return undefined
const session = source.kind === 'attached'
? { header: source.session.header, events }
: { header: source.header, events }
try {
return await presets.standingKeyFor(resolveSessionPreset(session))
} catch {
return undefined
}
}
private projectionsFor(
address: SessionAddress,
source: SessionSource,
@@ -368,76 +308,9 @@ function paginate(
return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
}
function entryFor(
ctx: Context,
event: SessionEvent,
argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
scope?: ScopeKey,
): SessionEventEntry {
const view = viewFor(ctx, event, argsFor, scope)
function entryFor(event: SessionEvent): SessionEventEntry {
return {
// Session.append validates and freezes event data as JSON before publication.
event: event as unknown as SessionWireEvent,
...(view === undefined ? {} : { view }),
}
}
function viewFor(
ctx: Context,
event: SessionEvent,
argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
scope?: ScopeKey,
): SessionToolView | undefined {
if (event.type !== 'tool/call' && event.type !== 'tool/result') return undefined
const tools = ctx.get('tools')
/* v8 ignore next -- deployments without the optional Tools service omit presentation metadata. */
if (tools === undefined) return undefined
try {
if (event.type === 'tool/call') {
const data = event.data as ToolCallData
const view: ToolCallView | undefined = tools.get(data.name, scope)?.presentCall?.(JSON.parse(data.arguments))
return view === undefined ? undefined : { for: 'call', view: jsonView(view) }
}
const [result] = event.data.message.content
const call = argsFor(event.data.message.source.callId)
if (call === undefined) return undefined
const view: ToolResultView | undefined = tools.get(call.name, scope)?.presentResult?.(call.args, {
content: result.content,
isError: result.isError === true,
...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
})
return view === undefined ? undefined : { for: 'result', view: jsonView(view) }
} catch (error) {
ctx.logger.warn(`session: presenter failed for ${event.type}: ${String(error)}`)
}
return undefined
}
function backscanArgs(
events: readonly SessionEvent[],
callId: string,
): { readonly name: string; readonly args: unknown } | undefined {
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index] as SessionEvent
if (event.type !== 'tool/call') continue
const data = event.data as ToolCallData
if (data.callId !== callId) continue
return parseToolCall(data)
}
return undefined
}
function parseToolCall(data: ToolCallData): { readonly name: string; readonly args: unknown } | undefined {
try {
return { name: data.name, args: JSON.parse(data.arguments) }
} catch {
return undefined
}
}
function jsonView(view: ToolCallView): SessionToolCallView
function jsonView(view: ToolResultView): ToolResultView
function jsonView(view: ToolCallView | ToolResultView): SessionToolCallView | ToolResultView {
const encoded = JSON.stringify(view)
return JSON.parse(encoded) as SessionToolCallView | ToolResultView
}
+1 -2
View File
@@ -69,7 +69,6 @@ export class SessionController extends TypertRemoteService {
'llm',
'sessions',
'sessionQuery',
'tools',
'typert',
'workspaceRegistry',
]
@@ -260,7 +259,7 @@ export class SessionController extends TypertRemoteService {
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
* @param signal - cancellation for persistence and presentation reads.
* @param signal - cancellation for persistence reads.
* @returns one chronological page and optional latest projections.
*/
@Remote('page')
+1 -19
View File
@@ -10,12 +10,6 @@ import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/t
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type {
DiffCallView,
GenericCallView,
TerminalCallView,
ToolResultView,
} from '@deepseek-ai/dsh-tools/presentation'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
@@ -333,21 +327,9 @@ export type SessionAddress =
readonly mode: 'one-shot' | 'continuable'
}
/** JSON-safe call render intent crossing the Session Remote boundary. */
export type SessionToolCallView =
| (Omit<GenericCallView, 'rawInput'> & { readonly rawInput?: JsonValue })
| TerminalCallView
| DiffCallView
/** Host-computed render intent accompanying one tool event. */
export type SessionToolView =
| { readonly for: 'call'; readonly view: SessionToolCallView }
| { readonly for: 'result'; readonly view: ToolResultView }
/** One raw Session event plus its optional transient render intent. */
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
readonly event: SessionWireEvent
readonly view?: SessionToolView
}
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
@@ -5,6 +5,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
import SessionController from '../src/index.ts'
import { createSessionTestController } from './test-remote.ts'
const defaults = {
@@ -13,6 +14,10 @@ const defaults = {
}
describe('SessionController facade', () => {
it('does not require the Tools service', () => {
expect(SessionController.inject).not.toContain('tools')
})
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -142,7 +142,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
]
}
/** Wrap raw events as view-less history entries (the wire shape history returns). */
/** Wrap raw events in the journal envelope returned by history. */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}
@@ -1,38 +1,15 @@
/**
* Tool-card view computation over Session Controller history and follow: three standard card types
* arrive on the frame, a presenterless tool ships no view field, a call-only
* presenter keeps raw result content out of the view payload, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing
* works for both paged and live entries.
*/
/** Raw Session journal transport and message-aligned pagination coverage. */
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote } from './test-remote.ts'
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
return defineContentToolFixture({
name,
description: `tool ${name}`,
parameters: {},
execute: () => reply(`ran:${name}`),
...presenters,
})
}
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
@@ -65,27 +42,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
}))
ctx.tools.register(tool('term', {
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
presentResult: () => ({ card: 'terminal', output: 'done' }),
}))
ctx.tools.register(tool('diffy', {
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
}))
ctx.tools.register(tool('call-only', {
presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
}))
ctx.tools.register(tool('plain', {}))
ctx.tools.register(tool('boom', {
presentCall: () => { throw new Error('presenter exploded') },
}))
return { ctx }
}
@@ -119,73 +76,37 @@ async function openFollow(
return { [Symbol.asyncIterator]: () => iterator }
}
describe('Session history view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
describe('Session history raw journal', () => {
it('follows raw tool events and preserves result metadata without a Tools service', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 9, abort)
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
session.append('tool/result', {
const collected = collect(stream, 2, abort)
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed',
})
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-call-only'),
content: [{ type: 'text', text: rawResult }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-gen'),
content: [{ type: 'text', text: 'ok' }],
callId: CallId('raw-call'),
content: [{ type: 'text', text: 'raw output' }],
isError: false,
}),
meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] },
}, { surfaceOp: 'append' })
const frames = await collected
const events = frames.filter(f => f.type === 'event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [
`${f.event.type}:${f.event.type === 'tool/call'
? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
: (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
f,
]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
for: 'call',
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
})
const callOnlyResult = byCall.get('tool/result:c-call-only')
expect('view' in (callOnlyResult ?? {})).toBe(false)
const serializedResult = JSON.stringify(callOnlyResult)
expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
// No presenter → the frame carries no view property at all.
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
// Throwing presenter → soft-fall: event ships, no view.
expect(byCall.get('tool/call:c-boom')).toBeDefined()
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
// Result pairing through the live table: presentResult saw the call's args.
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
expect(frames).toEqual([
{ type: 'event', event: call },
{ type: 'event', event: result },
])
expect((frames[1] as Extract<SessionFollowFrame, { type: 'event' }>).event.data)
.toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } })
})
it('pairs live results from the open-call table without rescanning Session history', async () => {
it('follows live results without rescanning Session history', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
@@ -197,7 +118,7 @@ describe('Session history view computation', () => {
turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
})
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } },
value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
})
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
@@ -213,7 +134,7 @@ describe('Session history view computation', () => {
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } },
value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } },
})
} finally {
events.mockRestore()
@@ -223,53 +144,22 @@ describe('Session history view computation', () => {
}
})
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
it('serves raw call and result entries without parsing tool arguments', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', {
const start = session.append('turn/start', { turn: 1 })
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
})
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-term'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
meta: { n: 1 },
}, { surfaceOp: 'append' })
// Unpaired result: no tool/call with this id anywhere in the page.
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-orphan'),
content: [{ type: 'text', text: 'x' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-bad'),
content: [{ type: 'text', text: 'y' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Presenterless tool: pairing succeeds but presentResult is absent.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-plain'),
content: [{ type: 'text', text: 'z' }],
isError: false,
callId: CallId('history-call'),
content: [{ type: 'text', text: 'failed raw output' }],
isError: true,
}),
meta: { persisted: true, count: 3 },
}, { surfaceOp: 'append' })
const response = await remote.page({
@@ -278,27 +168,17 @@ describe('Session history view computation', () => {
})
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
const entries = response.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [
`${entry.event.type}:${entry.event.type === 'tool/call'
? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
: (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
entry,
]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
expect(response.value.events).toEqual([
{ event: start },
{ event: call },
{ event: result },
])
})
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
@@ -348,7 +228,6 @@ describe('Session history view computation', () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
turn: 1,
@@ -384,28 +263,41 @@ describe('Session history view computation', () => {
}
})
it('pairs a followed result after turn/end from the addressed Session log', async () => {
it('follows a result after turn/end without reading the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 4, abort)
const iterator = stream[Symbol.asyncIterator]()
session.append('turn/start', { turn: 1 })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-late'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const frames = await collected
const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result')
expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
try {
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-late'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toEqual({
done: false,
value: { type: 'event', event: result },
})
} finally {
events.mockRestore()
abort.abort()
await iterator.next()
await ctx.fiber.dispose()
}
})
})
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
@@ -26,12 +25,10 @@ function makeSession(
function follow(
api: FakeApiClient,
event: SessionEvent,
view?: SessionToolView,
): Promise<void> {
return api.pushFollow(SID, {
type: 'event',
event: event as never,
...(view === undefined ? {} : { view }),
})
}
@@ -44,7 +41,7 @@ function eventSeqs(session: Session): number[] {
}
function histResponse(events: SessionEvent[], hasMore = false) {
// history returns HistoryEntry[] ({event, view?}); these tests are view-less.
// History returns raw journal envelopes around each event.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -601,39 +598,30 @@ describe('remaining branches', () => {
await expect(session.dispose()).resolves.toBeUndefined()
})
it('carries history-entry and follow-frame views through the event feed', async () => {
it('carries raw history and follow events through the event feed', async () => {
const { api, session } = makeSession()
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
const historyResult = ev.toolResult(7, 1, 'h1', 'done')
api.onHistory = () => Promise.resolve(ok({
events: [
...entries(plainTurn(0, 0, 'a', 'b')),
{ event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
{ event: historyCall },
{ event: historyResult },
] as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([
callView,
{ for: 'result', view: { card: 'generic', title: '历史果' } },
expect(windowEntries(session).slice(-2)).toEqual([
{ event: historyCall },
{ event: historyResult },
])
await follow(
api,
ev.toolCall(8, 2, 'l1', 'write', '{}'),
{ for: 'call', view: { card: 'generic', title: '直播卡' } },
)
expect(windowEntries(session).at(-1)?.view).toEqual({
for: 'call', view: { card: 'generic', title: '直播卡' },
})
await follow(
api,
ev.toolResult(9, 2, 'l1', 'ok'),
{ for: 'result', view: { card: 'generic', title: '直播果' } },
)
expect(windowEntries(session).at(-1)?.view).toEqual({
for: 'result', view: { card: 'generic', title: '直播果' },
})
const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
await follow(api, liveCall)
expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
await follow(api, liveResult)
expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
})
})
@@ -482,60 +482,6 @@ describe('SessionHistoryController', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
})
it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => {
const live = await setup()
const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } })
const liveAgent = { id: liveSession.id }
const preset = vi.fn(() => Promise.resolve('preset-scope'))
live.ctx.provide('agents', { get: () => liveAgent } as never)
live.ctx.provide('agentPresets', { standingKeyFor: preset } as never)
await live.transport.page({
address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1,
}, signal())
expect(preset).not.toHaveBeenCalled()
const attached = await setup()
const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), {
meta: { cwd: '/workspace', agentPreset: 'minimal' },
})
const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope'))
attached.ctx.provide('agentPresets', { standingKeyFor } as never)
await attached.transport.page({
address: { kind: 'session', sessionId: attachedSession.id },
throughSeq: -1,
}, signal())
expect(standingKeyFor).toHaveBeenCalledWith('minimal')
const detached = await setup()
const detachedId = SessionId('detached-scope')
const header = {
version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
}
cold(detached.ctx, header, [])
const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable')))
detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never)
await expect(detached.transport.page({
address: { kind: 'session', sessionId: detachedId },
throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [] })
expect(rejected).toHaveBeenCalledWith('standard')
const switched = await setup()
const switchedId = SessionId('switched-scope')
const switchedHeader = {
version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
}
cold(switched.ctx, switchedHeader, [
event('agent-preset/selected', 0, { agentPreset: 'minimal' }),
])
const switchedKey = vi.fn(() => Promise.resolve('switched-scope'))
switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never)
await switched.transport.page({
address: { kind: 'session', sessionId: switchedId }, throughSeq: 0,
}, signal())
expect(switchedKey).toHaveBeenCalledWith('minimal')
})
it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } })
@@ -576,77 +522,4 @@ describe('SessionHistoryController', () => {
expect(page.hasMore).toBe(false)
})
it('projects tool call and result views and contains malformed presenters', async () => {
const { ctx, transport } = await setup()
const sessionId = SessionId('presenters')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const events = [
event('fixture/start', 0),
event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }),
event('tool/result', 2, {
message: {
source: { callId: 'c1' },
content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }],
},
meta: { persisted: true },
}),
event('tool/result', 3, {
message: {
source: { callId: 'missing' },
content: [{ content: [{ type: 'text', text: 'missing' }] }],
},
}),
event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }),
event('tool/result', 5, {
message: {
source: { callId: 'c2' },
content: [{ content: [{ type: 'text', text: 'bad args' }] }],
},
}),
event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }),
event('tool/result', 7, {
message: {
source: { callId: 'c3' },
content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }],
},
}),
event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }),
event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }),
event('tool/result', 10, {
message: {
source: { callId: 'c5' },
content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }],
},
}),
]
cold(ctx, header, events)
ctx.provide('tools', {
get: (name: string) => {
if (name === 'present') {
return {
presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }),
presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }),
}
}
if (name === 'empty') return {}
if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } }
if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } }
return undefined
},
} as never)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const page = await transport.page({
address: { kind: 'session', sessionId }, throughSeq: 10,
}, signal())
expect(page.events[1]?.view).toEqual({
for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } },
})
expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } })
for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) {
expect(page.events[index]).not.toHaveProperty('view')
}
expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed'))
})
})
@@ -21,7 +21,6 @@
{ "path": "../../llm/llm" },
{ "path": "../../session/session-projection" },
{ "path": "../../session/session-title" },
{ "path": "../../core/tools" },
{ "path": "../../util/brand" },
{ "path": "../../util/crypto" },
{ "path": "../../util/workspace-path" },
@@ -24,7 +24,6 @@
{ "path": "../../core/agent-default-model" },
{ "path": "../../core/scope" },
{ "path": "../../core/session" },
{ "path": "../../core/tools" },
{ "path": "../../attachment/attachment" },
{ "path": "../../interaction/permission-presets" },
{ "path": "../../jobs/jobs" },
+2 -4
View File
@@ -55,8 +55,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
"@deepseek-ai/dsh-tool-todo": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
@@ -67,7 +66,6 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
"@deepseek-ai/dsh-tool-todo": "workspace:^"
}
}
@@ -17,7 +17,6 @@ export type {
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, RpcMessage,
+152 -248
View File
@@ -17,6 +17,7 @@ import type {
} from '@deepseek-ai/dsh-llm'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
JsonValue,
SessionEvent,
SessionId,
} from '@deepseek-ai/dsh-session/types'
@@ -29,7 +30,6 @@ import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surfac
import type {
ApiProxy, ClientRequest,
ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse,
ToolCallView, ToolResultView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
@@ -71,13 +71,8 @@ interface FixtureProjectionsBlock {
readonly values: Readonly<Record<string, unknown>>
}
type FixtureToolView =
| { readonly for: 'call'; readonly view: ToolCallView }
| { readonly for: 'result'; readonly view: ToolResultView }
interface FixtureHistoryEntry {
readonly event: SessionEvent
readonly view?: FixtureToolView
}
type FixtureSessionAddress =
@@ -362,11 +357,9 @@ function sgr(code: number, body: string): string {
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
* height cap collapses the middle. The exit status is authored separately in
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
* terminal card shows the exit as its own pill and leaving the marker in would
* render it twice (packages/shell/tool-bash/src/render.ts).
* height cap collapses the middle. This constant is the visible body; the call
* site appends the shell result's `[exit code: N]` marker so Client derivation
* can consume it into the terminal status pill.
*/
const TERMINAL_OUTPUT_FIXTURE = [
sgr(1, 'Running 4 checks'),
@@ -393,20 +386,10 @@ const TERMINAL_OUTPUT_FIXTURE = [
].join('\n')
/**
* Exit status for each terminal sample, keyed by its output text. Authored
* alongside the sample rather than parsed back out of its trailing marker,
* which is the bash tool's own job and not something to reimplement here.
*/
const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
/**
* Structured grep result for the search sample (turn 67): matches grouped by
* file, authored inline because the client-side fixture cannot import the tool
* that produces the canonical value. `truncated` with a larger `total` than the
* retained match count exercises the search card's capped indicator; the file
* with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap.
* Structured grep metadata for the search sample (turn 67). `truncated` with a
* larger `total` than the retained match count exercises the search card's
* capped indicator; the file with more than CHAT_SEARCH_MAX_LINES rows
* exercises its head/tail height cap.
*/
const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [
{
@@ -475,18 +458,23 @@ const READ_SAMPLE_SOURCE = [
const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
const READ_SAMPLE_TOTAL = 180
const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
const READ_SAMPLE_LAST_LINE = READ_SAMPLE_FIRST_LINE + READ_SAMPLE_SOURCE.length - 1
const READ_SAMPLE_TEXT = [
`<path>${READ_SAMPLE_PATH}</path>`,
'<type>file</type>',
'<content>',
...READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`),
'',
`(Showing lines ${READ_SAMPLE_FIRST_LINE}-${READ_SAMPLE_LAST_LINE} of ${READ_SAMPLE_TOTAL}. Use offset=${READ_SAMPLE_LAST_LINE + 1} to continue.)`,
'</content>',
].join('\n')
/**
* The structured `web_search` result view for the web-search turn, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
* a snippet but no date, and a source with a title and a date but no snippet.
* `truncated` marks the capped indicator. The shape is the contract's own
* search view minus its wire discriminants.
* The `web_search` result metadata for the web-search turn. The sources cover a
* titled source with a snippet and date, a hostname-label fallback, and a
* titled source without a snippet; `truncated` exercises the capped indicator.
*/
const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
const WEB_SEARCH_META = {
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
sources: [
{
@@ -506,14 +494,14 @@ const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'sear
},
],
truncated: true,
}
} satisfies JsonValue
/** The `web_fetch` result view for the web-fetch turn, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
/** The `web_fetch` result metadata for the web-fetch turn. */
const WEB_FETCH_META = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
truncated: false,
}
} satisfies JsonValue
const DEEPSEEK_REASONING = {
efforts: [
@@ -649,10 +637,16 @@ function buildAlphaLog(): SessionEvent[] {
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
// stays presenter-less as the unknown fallback.
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
// The structured samples use real first-party names and result metadata so
// the fixture follows the same event-to-card path as a persisted Session.
// `echo` above remains the unknown-tool fallback.
const toolTurn = (
turn: number,
name: string,
args: string,
resultText: string,
resultMeta?: JsonValue,
): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
@@ -662,23 +656,66 @@ function buildAlphaLog(): SessionEvent[] {
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
push({
type: 'tool/result',
surfaceOp: 'append',
data: {
turn,
step: 0,
message: toolResultMessage(callId, text(resultText), false),
...resultMeta === undefined ? {} : { meta: resultMeta },
},
})
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// A two-line command, so the fixture covers the terminal card's one-row-per-
// command-line prompt (and that the card still marks the call exactly once).
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
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"}', '已写入')
toolTurn(
60,
'bash',
'{"command":"ls -la\\necho done","description":"fixture 终端样本","workdir":"/tmp/fixture"}',
'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt',
)
toolTurn(
61,
'write',
'{"file_path":"notes/demo.txt","content":"hello fixture\\n"}',
'wrote notes/demo.txt',
{ diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'hello fixture\n' }] },
)
toolTurn(
62,
'edit',
'{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}',
'已编辑',
{ diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
)
toolTurn(
63,
'write',
'{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}',
'已写入',
{ diffs: [{ path: 'notes/new-demo.txt', oldText: null, newText: 'hello fixture\n' }] },
)
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
// the presenter reads to emit the two-hunk sample: the card draws one path
// header, the first hunk, a `⋯` gap, then the second (the same-file
// single-hunk turn 62 also uses). Its result metadata carries two scattered
// hunks under one path header, so the card draws the first hunk, a `⋯` gap,
// then the second (the same-file
// second-hunk arm turns 62/63 cannot reach).
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
toolTurn(
64,
'edit',
'{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}',
'已编辑',
{
diffs: [
{ path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
{ path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
],
},
)
// Turn 65: 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
@@ -734,52 +771,75 @@ function buildAlphaLog(): SessionEvent[] {
]
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
// whose prompt label is its last segment, and a non-zero exit authored beside
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
// `[exit code: N]` marker, since the real presenter consumes that one out of
// the body. Named `bash`, so it also covers
// the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
// row) — the two chat-row shapes the terminal card renders in.
// whose prompt label is its last segment, and a non-zero exit. The raw result
// includes an `[exit code: N]` marker below; Client
// derivation consumes it into the status pill before rendering the body.
//
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
// next `turn/start`, so a turn appended after it would leave the dock's plan
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
toolTurn(
66,
'bash',
'{"command":"pnpm run check","description":"fixture 终端样本","workdir":"/tmp/fixture/deep/nested"}',
`${TERMINAL_OUTPUT_FIXTURE}\n[exit code: 1]`,
)
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
// truncated). Both ride the keyed SearchRow registration under their own
// names; the render-site fallback row is covered by the model derivation
// tests, since every fixture search tool has a keyed row. Ordered before the
// todo turn for the same standing-plan reason the bash turn is.
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
// Turns 67-68 carry the search card's two metadata variants: grouped matches
// and a flat path list, both truncated with a larger pre-cap total. Both use
// the keyed SearchRow registration. They stay before the todo turn for the
// same standing-plan reason as the bash turn.
toolTurn(
67,
'grep',
'{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}',
SEARCH_MATCHES_TEXT,
{ shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 },
)
toolTurn(
68,
'glob',
'{"pattern":"**/SearchBlock*","path":"packages/client"}',
SEARCH_PATHS_TEXT,
{ shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 },
)
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
// line numbers starting above 1 and a "showing N of M" note (the window is
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
// The render-site fallback ROW SHAPE (a read call on the generic flattened
// path) is covered by the turn 65 run_code read sub-dispatches, which
// session.ts folds with resultView: null; the fallback-row + read-CARD
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
// this fixture. The read render intent is result-side only, so its pending
// call stays a generic `kind: 'read'` card; presentResult carries the
// structured window.
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
// The run_code sub-dispatches above cover nested read calls without result
// metadata; this top-level result carries the structured window.
toolTurn(
69,
'read',
`{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`,
READ_SAMPLE_TEXT,
{
path: READ_SAMPLE_PATH,
offset: READ_SAMPLE_FIRST_LINE,
lines: READ_SAMPLE_LINES,
totalLines: READ_SAMPLE_TOTAL,
lang: 'ts',
},
)
// Turns 70-71: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
// Both keep a generic pending call view and add the `web` card only at
// result time, which is the contract's result-only web shape. Named after
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 66 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.')
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
// Turns 70-71 carry the web tools' result metadata. They stay before the todo
// turn because a later turn/start retires the standing plan projection.
toolTurn(
70,
'web_search',
'{"queries":["deepseek harness architecture"]}',
'Search results for deepseek harness architecture.',
WEB_SEARCH_META,
)
toolTurn(
71,
'web_fetch',
'{"url":"https://www.deepseek.com/blog/harness-architecture"}',
'# Harness architecture\n\nEverything is a plugin.',
WEB_FETCH_META,
)
// Turn 72: max-tokens sample — the provider ends the turn at its output cap
// mid-sentence, so the chat flow must render the turn-max-tokens notice
@@ -832,151 +892,6 @@ function buildAlphaLog(): SessionEvent[] {
return events as unknown as SessionEvent[]
}
/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */
/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */
const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback
/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */
function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
let args: Record<string, unknown>
try {
args = JSON.parse(argsRaw) as Record<string, unknown>
} catch {
/* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */
return undefined
}
switch (name) {
// Both names present the same terminal card: `fx-bash` lands on the
// render-site fallback row, `bash` on the keyed BashRow registration.
case 'fx-bash':
case 'bash':
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
case 'fx-write':
return {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
// A read pending call is a GENERIC card (kind: 'read', a follow-along
// location): the read render intent is result-side only, because a call
// carries no file content until execute returns. The rich read card arrives
// in presentResult.
case 'read':
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
case 'edit':
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
// scattered hunks share one path header and the card draws the `⋯` gap.
if (str(args.file_path) === 'src/config.ts') {
return {
card: 'diff', title: `Edit ${str(args.file_path)}`,
diffs: [
{ path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
{ path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
],
}
}
return {
card: 'diff', title: `Edit ${str(args.file_path)}`,
diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
}
case 'write':
return {
card: 'diff', title: `Write ${str(args.file_path)}`,
diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
}
// A search call stays a generic card (kind: 'search'): the structured
// matches/paths exist only after execute, so the search card is result-time
// only (presentResult builds it). This mirrors the real grep/glob presenters.
case 'grep':
return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args }
case 'glob':
return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args }
// The web tools keep a GENERIC pending card and add the `web` result card
// only at result time (the contract's result-only web shape); their pending
// kind matches the result kind so a call and its result read as one category.
case 'web_search': {
const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : []
const title = queries.join(', ')
return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
}
case 'web_fetch':
return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
}
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
// Search is result-time only: the call stays a generic search card, and the
// result view carries the structured shape the card renders. The view holds no
// result text — a UI without a search card falls back to the raw tool/result
// content — so the truncation recovery footer rides that raw content (the
// `toolTurn` message text), not the view. `total` exceeds the retained count so
// the card shows its capped indicator.
if (name === 'grep') {
return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }
}
if (name === 'glob') {
return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }
}
// The read result is the structured window the tool projects through
// `presentationMeta`; the fixture authors it inline (it cannot import the
// tool). Keyed on the name because the read pending call is a generic card,
// so `call.card` alone does not distinguish it from edit/write.
if (name === 'read') {
return {
card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES,
totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
}
}
// The web tools keep a generic pending card, so their result card is chosen
// by tool name rather than by the pending card tag: the structured `web` card
// the frontend consumes. The view carries no `content` copy (per the contract
// and the web-result-card note); a capability-less UI falls back to the raw
// `tool/result` content, which this fixture emits from `resultText`.
if (name === 'web_search') {
return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
}
if (name === 'web_fetch') {
return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
}
switch (call.card) {
case 'terminal':
// The sample's own exit status, authored beside it: re-parsing the
// trailing marker here would duplicate the bash tool's `parseExitStatus`,
// which this client-side fixture cannot import.
return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
case 'diff':
return { card: 'diff', diffs: call.diffs }
case 'generic':
return { card: 'generic', content: text(resultText) }
}
}
/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
function viewFor(event: SessionEvent, log: readonly SessionEvent[]): FixtureToolView | undefined {
if (event.type === 'tool/call') {
const view = presentCall(event.data.name, event.data.arguments)
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const callId = String(event.data.message.source.callId)
for (let i = log.length - 1; i >= 0; i--) {
const candidate = log[i]
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
so the undefined arm needs a sparse log no code path builds. */
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
return view === undefined ? undefined : { for: 'result', view }
}
}
return undefined // cross-page unpaired: documented default
}
return undefined
}
/**
* Fixture parallel of the plan unit's lifecycle fold. The paired
* `command/done` retains successful plan selections and drops failures;
@@ -1422,11 +1337,9 @@ function projectionFramesOf(
}
/**
* Message-boundary paging (mirrors the host's paging contract): count
* maxMessages messages
* backwards from end, cut at a turn/start boundary.
Entries carry pagination-time views
* (the host analogue computes viewFor per entry at page time). */
* Message-boundary paging mirrors the Host contract: count `maxMessages`
* backwards from the end and cut at a turn/start boundary.
*/
function pageOf(
log: readonly SessionEvent[],
beforeSeq: number | undefined,
@@ -1445,10 +1358,7 @@ function pageOf(
break
}
}
const events = log.slice(start, end).map((event): FixtureHistoryEntry => {
const view = viewFor(event, log)
return view === undefined ? { event } : { event, view }
})
const events = log.slice(start, end).map((event): FixtureHistoryEntry => ({ event }))
return { events, hasMore: start > 0 }
}
@@ -1974,12 +1884,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const log = logOf(id)
const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
log.push(event)
// Emission-time view derivation (mirrors the host's live path).
const view = viewFor(event, log)
/* v8 ignore next 2 -- the view-present arm needs a live tool/call emission,
but the fixture replay produces text-only turns; view vocabulary is
exercised through the history samples (turns 60-62). */
emitFollow(id, view === undefined ? { event } : { event, view })
emitFollow(id, { event })
// Host eager-drive parallel: a unit-advancing event pushes its finished value.
for (const frame of projectionFramesOf(id, log, event)) emitControl(frame)
if (event.type === 'user/message' && event.data.source.kind === 'user') {
@@ -3007,8 +2912,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`)
}
nextSeq++
const view = viewFor(event, snapshot)
yield view === undefined ? { type: 'event', event } : { type: 'event', event, view }
yield { type: 'event', event }
}
}
for await (const frame of conn.drain(signal)) {
@@ -32,7 +32,6 @@ declare module '@deepseek-ai/cordis' {
export type {
ApiProxy, HostApi,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView,
SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection,
@@ -37,7 +37,6 @@ interface FixtureSessionSummary {
interface FixtureHistoryEntry {
readonly event: SessionEvent
readonly view?: unknown
}
interface FixturePage {
@@ -648,6 +647,49 @@ describe('createFixtureApi', () => {
})
})
it('serves raw history entries with replayable tool-result metadata', async () => {
const api = createFixtureApi()
const response = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 200 }))
if (!response.result.ok) throw new Error('history failed')
const entries = response.result.value.events
expect(entries.every(entry => !Object.hasOwn(entry, 'view'))).toBe(true)
const results = entries
.map(entry => entry.event)
.filter(event => event.type === 'tool/result')
expect(results.find(event => event.data.turn === 64)).toMatchObject({
data: {
meta: {
diffs: [
{ path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
{ path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
],
},
},
})
expect(results.find(event => event.data.turn === 67)).toMatchObject({
data: { meta: { shape: 'matches', truncated: true, total: 42 } },
})
expect(results.find(event => event.data.turn === 69)).toMatchObject({
data: { meta: { path: 'packages/client/ui-primitives/src/ReadBlock.tsx', offset: 41, totalLines: 180 } },
})
const webSearch = results.find(event => event.data.turn === 70)
expect(webSearch).toHaveProperty('data.meta.truncated', true)
expect(webSearch).toHaveProperty('data.meta.sources', expect.arrayContaining([
expect.objectContaining({ url: 'https://github.com/deepseek-ai/deepseek-harness' }),
]))
expect(results.find(event => event.data.turn === 71)).toMatchObject({
data: { meta: { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200 } },
})
const terminal = results.find(event => event.data.turn === 66)
expect(terminal).toHaveProperty('data.message.content.0.content.0.type', 'text')
expect(terminal).toHaveProperty(
'data.message.content.0.content.0.text',
expect.stringContaining('\n[exit code: 1]'),
)
})
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-alpha')
-6
View File
@@ -1059,9 +1059,6 @@ importers:
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
@@ -1806,9 +1803,6 @@ importers:
'@deepseek-ai/dsh-tool-todo':
specifier: workspace:^
version: link:../../todo/tool-todo
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
packages/client/hmr:
dependencies: