refactor(session): stream raw tool events
This commit is contained in:
1 parent
64c9e4a22c
commit
a42c0b523a
20 files changed
+300
-767
No files matched your search
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
+62
-170
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
Generated
-6
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user