Merge worktree/schedule-conversational-after into worktree/schedule-explicit-at
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* connection handle, stream-loop sink wiring into the object layer, and the
|
||||
* fiber-scoped loop teardown.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -126,6 +126,7 @@ describe('runtime client apply', () => {
|
||||
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'registry-probe',
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
|
||||
@@ -30,10 +30,16 @@ interface TestSnapshot {
|
||||
}
|
||||
|
||||
class TestEventDefinitions {
|
||||
readonly definitions: readonly ConversationNodeDefinition[]
|
||||
readonly fallback: ConversationNodeDefinition | undefined
|
||||
|
||||
constructor(
|
||||
readonly definitions: readonly ConversationNodeDefinition[],
|
||||
readonly fallback?: ConversationNodeDefinition,
|
||||
) {}
|
||||
definitions: readonly ConversationNodeDefinition[],
|
||||
fallback?: ConversationNodeDefinition,
|
||||
) {
|
||||
this.definitions = definitions
|
||||
this.fallback = fallback
|
||||
}
|
||||
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return this.definitions
|
||||
@@ -93,7 +99,10 @@ function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | unde
|
||||
return assembler.snapshot('chat') as TestSnapshot | undefined
|
||||
}
|
||||
|
||||
function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode {
|
||||
function node(
|
||||
context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[0],
|
||||
data: unknown,
|
||||
): ConversationViewNode {
|
||||
return {
|
||||
key: context.key,
|
||||
kind: context.kind,
|
||||
@@ -103,6 +112,17 @@ function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
|
||||
return {
|
||||
kind: 'fallback',
|
||||
target: 'chat',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConversationNodeAssembler', () => {
|
||||
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
|
||||
const starts = vi.fn((
|
||||
@@ -122,6 +142,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: starts,
|
||||
update: updates,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -173,6 +194,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
matchCollections.add(context.matches)
|
||||
return updates(context)
|
||||
},
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -208,6 +230,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: starts,
|
||||
update: updates,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -247,6 +270,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => ({ settled: false }),
|
||||
update: updates,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -280,6 +304,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -301,6 +326,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumerStart = vi.fn((
|
||||
@@ -315,6 +341,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -344,6 +371,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: (_context, match) => match.event.seq,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
@@ -353,6 +381,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -397,6 +426,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -425,6 +455,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => 1,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumerStart = vi.fn((
|
||||
@@ -439,6 +470,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -468,6 +500,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => 1,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const sourceX: ConversationNodeDefinition<number> = {
|
||||
@@ -479,6 +512,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => 10,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const middle: ConversationNodeDefinition<number> = {
|
||||
@@ -491,6 +525,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
+ (reader.previous<number>('diamond-x')?.state ?? 0)
|
||||
),
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
@@ -503,6 +538,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
+ (reader.previous<number>('diamond-b')?.state ?? 0)
|
||||
),
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -538,6 +574,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: starts,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -609,6 +646,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
value: { valueSeenFromStep: stepValue ?? -1 },
|
||||
}
|
||||
},
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
if (location?.kind !== 'step') return null
|
||||
@@ -646,6 +684,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
|
||||
? context.start.location.turn.steps.length
|
||||
: -1),
|
||||
@@ -701,6 +740,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
const data = location?.kind === 'step'
|
||||
@@ -734,6 +774,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.start?.location.kind),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -760,6 +801,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
return node(context, location?.kind === 'step'
|
||||
@@ -792,6 +834,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
return node(context, location?.kind === 'step'
|
||||
@@ -826,6 +869,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: seen,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -841,24 +885,64 @@ describe('ConversationNodeAssembler', () => {
|
||||
expect(seen).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
|
||||
it('invokes the fallback when only a State-only Definition claims an event', () => {
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const claimed: ConversationNodeDefinition<null> = {
|
||||
kind: 'claimed-state',
|
||||
match: event => (event.type as string) === 'command/run'
|
||||
? { id: 'claimed', role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(fallbackStart).toHaveBeenCalledOnce()
|
||||
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('invokes the fallback when only another target claims an event', () => {
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const claimed: ConversationNodeDefinition<null> = {
|
||||
kind: 'claimed-trajectory',
|
||||
target: 'trajectory',
|
||||
match: event => (event.type as string) === 'command/run'
|
||||
? { id: 'claimed', role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(fallbackStart).toHaveBeenCalledOnce()
|
||||
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('suppresses the fallback when the same target claims an event', () => {
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const claimed: ConversationNodeDefinition<null> = {
|
||||
kind: 'claimed',
|
||||
target: 'chat',
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const fallback: ConversationNodeDefinition<string> = {
|
||||
kind: 'fallback',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start: fallbackStart,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimed], fallback),
|
||||
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
@@ -878,6 +962,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => true,
|
||||
update: () => false,
|
||||
target: 'chat',
|
||||
buildViewNode: context => context.state === true ? node(context, true) : null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -900,6 +985,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
||||
start: () => undefined,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const startAssembler = new ConversationNodeAssembler(
|
||||
@@ -919,6 +1005,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => true,
|
||||
update: () => undefined as never,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const updateAssembler = new ConversationNodeAssembler(
|
||||
@@ -939,6 +1026,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
||||
start: (_context, match) => match.event.seq,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
|
||||
@@ -13,6 +13,7 @@ import { FakeApiClient, ok } from './fake-api.ts'
|
||||
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
||||
return {
|
||||
kind,
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
@@ -71,6 +72,40 @@ describe('Conversation registries', () => {
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects rendering Definitions that omit either target or builder', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const targetOnly: ConversationNodeDefinition<null> = {
|
||||
kind: 'target-only',
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
const builderOnly: ConversationNodeDefinition<null> = {
|
||||
kind: 'builder-only',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
|
||||
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
|
||||
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
|
||||
})
|
||||
|
||||
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const fallback: ConversationNodeDefinition<null> = {
|
||||
kind: 'state-only-fallback',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
|
||||
expect(() => events.registerFallback(fallback))
|
||||
.toThrow('conversation fallback Definition must declare a target')
|
||||
})
|
||||
|
||||
it('rejects duplicate view targets and disposes a view registration once', async () => {
|
||||
const { views } = await bootRegistries()
|
||||
const definition = viewDefinition('chat')
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
/** Assistant block classifier (moved here with sessions/conversation.ts). */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
|
||||
|
||||
describe('toAssistantBlock', () => {
|
||||
it('classifies the four block shapes', () => {
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const blocks: ContentBlock[] = [
|
||||
{ type: 'text', text: '正文' },
|
||||
{ type: 'reasoning', text: '思考' },
|
||||
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
|
||||
{ type: 'image', data: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'image', attachment },
|
||||
]
|
||||
expect(toAssistantBlocks(blocks)).toEqual([
|
||||
{ kind: 'text', text: '正文' },
|
||||
{ kind: 'reasoning', text: '思考' },
|
||||
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
|
||||
{ kind: 'other', block: blocks[3] },
|
||||
{ kind: 'image', attachment },
|
||||
])
|
||||
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
|
||||
})
|
||||
|
||||
@@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient {
|
||||
Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
@@ -129,6 +131,7 @@ export class FakeApiClient implements IApiClient {
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
@@ -208,6 +211,22 @@ export class FakeApiClient implements IApiClient {
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
import { ev } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
|
||||
|
||||
describe('projectConversationHistory', () => {
|
||||
it('names an injected context node from its durable source, like the live adapter', () => {
|
||||
// The fold declares its own node mapping (jscpd:ignore in the source), so
|
||||
// the source projection is pinned on both sides independently.
|
||||
const injected = at(0, {
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
|
||||
// A plugin source, because the client program does not see the host
|
||||
// packages that merge richer source kinds; those arms are pinned in
|
||||
// context-provenance.spec.ts.
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
|
||||
}),
|
||||
})
|
||||
const { contexts } = projectConversationHistory([{ event: injected }])
|
||||
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
||||
form: 'catalog',
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects next-step human input as durable steering', () => {
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'change course' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const events = [
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
]
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes).toMatchObject([{
|
||||
kind: 'steering', messageId: steering.id, seq: 2,
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
|
||||
const baseSeq = 400_000
|
||||
const events = [
|
||||
ev.user(baseSeq, 'loaded tail'),
|
||||
at(baseSeq + 1, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
|
||||
sourceEventSeqs: [baseSeq],
|
||||
data: {
|
||||
turn: 80,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'tail summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
|
||||
expect(projection.contexts.map(context => ({
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ originSeq: undefined, nodes: [baseSeq] },
|
||||
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
||||
sourceEventSeqs: [2, 1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
||||
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
||||
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects assistant timing and the active request header from history', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.stepStart(0, 1, 2),
|
||||
at(1, { type: 'request/header', data: {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'first' },
|
||||
tools: [],
|
||||
},
|
||||
} }),
|
||||
ev.chunkStart(2, 1, 2),
|
||||
ev.chunkText(3, 1, 'token', 2),
|
||||
ev.assistant(4, 1, 'done', 2),
|
||||
ev.stepStart(5, 2, 1),
|
||||
ev.chunkText(6, 2, 'next', 1),
|
||||
ev.assistant(7, 2, 'next done', 1),
|
||||
].map(event => ({ event })))
|
||||
|
||||
expect(projection.eventNodes[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_000,
|
||||
firstTokenTime: 1_700_000_000_003,
|
||||
completedTime: 1_700_000_000_004,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
|
||||
expect(projection.eventNodes.at(-1)).toMatchObject({
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_005,
|
||||
firstTokenTime: 1_700_000_000_006,
|
||||
completedTime: 1_700_000_000_007,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('projects nested dispatches onto settled and interrupted history calls', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.turnStart(0, 1),
|
||||
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
|
||||
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
|
||||
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
|
||||
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
|
||||
ev.toolResult(6, 1, 'settled', 'done'),
|
||||
ev.turnEnd(7, 1),
|
||||
ev.turnStart(8, 2),
|
||||
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
|
||||
ev.turnEnd(11, 2, 'aborted'),
|
||||
].map(event => ({ event })))
|
||||
|
||||
const settled = {
|
||||
callId: 'settled',
|
||||
subCalls: [{
|
||||
callId: 'settled:code:1',
|
||||
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
|
||||
}],
|
||||
}
|
||||
expect(projection.eventNodes).toMatchObject([settled])
|
||||
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
|
||||
expect(projection.interruptedNodes).toMatchObject([{
|
||||
callId: 'interrupted',
|
||||
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
|
||||
}])
|
||||
})
|
||||
|
||||
it('drops completed token payloads without changing inspection projections', () => {
|
||||
const events = [
|
||||
ev.user(0, 'before'),
|
||||
ev.stepStart(1, 1, 0),
|
||||
ev.chunkStart(2, 1),
|
||||
ev.chunkText(3, 1, ''),
|
||||
ev.chunkText(4, 1, 'first'),
|
||||
ev.chunkText(5, 1, ' discarded'),
|
||||
at(6, { type: 'assistant/chunk', data: {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
|
||||
} }),
|
||||
ev.assistant(7, 1, 'first discarded'),
|
||||
ev.compactSummary(8, 'summary', 0, 7),
|
||||
ev.compactCheckpoint(9, 8, 0, 7),
|
||||
ev.stepStart(10, 2, 0),
|
||||
ev.chunkStart(11, 2),
|
||||
ev.chunkText(12, 2, 'interrupted'),
|
||||
ev.turnEnd(13, 2, 'aborted'),
|
||||
]
|
||||
const raw = events.map(event => ({ event }))
|
||||
const compacted = compactHistoryInspectionEntries(raw)
|
||||
|
||||
expect(compacted.map(entry => entry.event.seq)).toEqual([
|
||||
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
|
||||
])
|
||||
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
|
||||
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* a fired key must already carry a bumped version (emission follows the
|
||||
* applied mutation), bogus payloads fail loud, foreign events pass.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RuntimeInvariant from '../src/invariant.ts'
|
||||
|
||||
@@ -1111,3 +1111,60 @@ describe('completed reminder', () => {
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background-task mirror', () => {
|
||||
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
|
||||
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
|
||||
})
|
||||
const tasksFrame = (sessionId: SessionId, tasks: unknown[]) =>
|
||||
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
|
||||
|
||||
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
|
||||
const first = manager.getListSnapshot().tasksBySession
|
||||
expect(first[S1]).toEqual([view()])
|
||||
expect(first[S2]?.[0]?.label).toBe('other')
|
||||
|
||||
// Last-wins: the newer whole set replaces, it does not merge.
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })]))
|
||||
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })])
|
||||
})
|
||||
|
||||
it('stores an emptied set as an absent key so absence and [] read alike', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, []))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 's' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
|
||||
})
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the rows when the session is removed, whichever stream lands first', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies list subscribers so an open header re-renders without a poll', async () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const seen = vi.fn()
|
||||
manager.subscribe(seen)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
// The notifier batches on a microtask; the frame itself is already applied.
|
||||
await Promise.resolve()
|
||||
expect(seen).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: unknown): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
|
||||
|
||||
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
|
||||
events.map(event => ({ event }))
|
||||
|
||||
describe('inspectRequests', () => {
|
||||
it('projects ordinary and compaction calls into one chronological request stream', () => {
|
||||
const events = [
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 5, outputTokens: 2 },
|
||||
}),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'compact/start', { turn: 1 }),
|
||||
at(6, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
rawOutput: [
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
usage: { inputTokens: 8, outputTokens: 3 },
|
||||
}),
|
||||
at(7, 'user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
})),
|
||||
at(8, 'compact/end', { turn: 1 }),
|
||||
]
|
||||
const snapshot = inspectRequests(entriesOf(events))
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'assistant',
|
||||
startSeq: 0,
|
||||
resultSeq: 3,
|
||||
status: 'complete',
|
||||
prompt: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
},
|
||||
promptChange: { seq: 1, kind: 'initial' },
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 5,
|
||||
resultSeq: 6,
|
||||
replacementSeq: 7,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
},
|
||||
])
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('does not promote a truncated resume or change header to the initial prompt', () => {
|
||||
for (const reason of ['resume', 'change'] as const) {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(10, 'step/start', { turn: 3, step: 1 }),
|
||||
at(11, 'request/header', {
|
||||
reason,
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'tail-window prompt',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
prompt: { system: 'tail-window prompt' },
|
||||
})
|
||||
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a prompt change once the preceding header is loaded', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'before',
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 1, step: 2 }),
|
||||
at(3, 'request/header', {
|
||||
reason: 'change',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'after',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[1]).toMatchObject({
|
||||
promptChange: {
|
||||
seq: 3,
|
||||
kind: 'system',
|
||||
previous: { system: 'before' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a standalone compaction owner without widening assistant turns', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
at(1, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
}),
|
||||
at(2, 'compact/end', { turn: null }),
|
||||
at(3, 'step/start', { turn: 2, step: 1 }),
|
||||
]))
|
||||
|
||||
const [compaction, assistant] = snapshot.requests
|
||||
expect(compaction).toMatchObject({
|
||||
purpose: 'compaction',
|
||||
turn: null,
|
||||
step: 0,
|
||||
status: 'complete',
|
||||
})
|
||||
expect(assistant).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
turn: 2,
|
||||
step: 1,
|
||||
status: 'running',
|
||||
})
|
||||
if (assistant?.purpose === 'assistant') {
|
||||
const turn: number = assistant.turn
|
||||
expect(turn).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
at(1, 'session/end-seed', {}),
|
||||
at(2, 'compact/start', { turn: null }),
|
||||
at(3, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'replacement summary' }],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
}),
|
||||
at(4, 'compact/end', { turn: null }),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 0,
|
||||
status: 'error',
|
||||
completedAt: 1_700_000_000_001,
|
||||
error: 'Compaction was interrupted before completion.',
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 2,
|
||||
status: 'complete',
|
||||
completedAt: 1_700_000_000_004,
|
||||
summary: [{ type: 'text', text: 'replacement summary' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(1, 'tool/code-dispatch-start', {
|
||||
parentCallId: 'parent',
|
||||
subCallId: 'nested',
|
||||
name: 'read',
|
||||
arguments: {},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
|
||||
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
|
||||
const retryUsage = {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
}
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: chunkUsage },
|
||||
}),
|
||||
at(2, 'llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 100,
|
||||
failure: { message: 'rate limited' },
|
||||
}),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: retryUsage },
|
||||
}),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'recovered' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
status: 'complete',
|
||||
usage: {
|
||||
inputTokens: 26,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps provider credential fragments out of projected request errors', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'turn/end', {
|
||||
turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 2, step: 1 }),
|
||||
at(3, 'turn/end', {
|
||||
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{ status: 'error', error: 'API key is invalid' },
|
||||
{ status: 'error', error: 'plugin exploded' },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: '{{tools}}',
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
const [request] = snapshot.requests
|
||||
expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -6,14 +6,14 @@
|
||||
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
|
||||
* dispose with the fiber.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only routed probe event.
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionHistorySource } from '../src/client/session-history/source.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const SID = 'history-s1' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads the tail first and prepends older pages on demand', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
|
||||
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
|
||||
return histResponse(pages[0]!, false)
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadTail()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(1)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
expect(source.getSnapshot().baseSeq).toBe(12)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([13, 15])
|
||||
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().baseSeq).toBe(0)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('pins a lazy inspection to the entries in its source snapshot', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadTail()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(6, 'later'),
|
||||
})
|
||||
|
||||
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 6])
|
||||
})
|
||||
|
||||
it('publishes multiple assistant chunks once per browser frame', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadTail()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
let notifications = 0
|
||||
const unsubscribe = source.subscribe(() => { notifications++ })
|
||||
const before = source.getSnapshot().inspection
|
||||
const finalizedNodes = before.eventNodes
|
||||
const requests = before.requests
|
||||
const contexts = before.contexts
|
||||
|
||||
for (const event of [
|
||||
ev.chunkStart(6, 1),
|
||||
ev.chunkText(7, 1, 'stream '),
|
||||
ev.chunkText(8, 1, 'content'),
|
||||
]) {
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(notifications).toBe(0)
|
||||
frames[0]?.(0)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(notifications).toBe(1)
|
||||
const streamed = source.getSnapshot().inspection
|
||||
expect(streamed.eventNodes).toBe(finalizedNodes)
|
||||
expect(streamed.requests).toBe(requests)
|
||||
expect(streamed.contexts).toBe(contexts)
|
||||
expect(streamed.partial?.blocks).toEqual([
|
||||
{ kind: 'text', text: 'stream content' },
|
||||
])
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.chunkText(9, 1, ' then final'),
|
||||
})
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.assistant(10, 1, 'stream content then final'),
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(notifications).toBe(2)
|
||||
const finalized = source.getSnapshot().inspection
|
||||
expect(finalized.eventNodes).not.toBe(finalizedNodes)
|
||||
expect(finalized.partial).toBeNull()
|
||||
frames[1]?.(0)
|
||||
await Promise.resolve()
|
||||
expect(notifications).toBe(2)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('stops loading when an older page fails to advance', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
: Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'page unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadTail()
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('finishes an already started older page after consumer cancellation', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) {
|
||||
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
|
||||
}
|
||||
olderStarted.resolve(undefined)
|
||||
return middle.promise
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
await source.loadTail(controller.signal)
|
||||
const complete = source.loadOlder(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
expect(await complete).toBe(true)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type {
|
||||
@@ -123,15 +124,20 @@ function testViewDefinition(): ConversationViewDefinition<ChatConversationViewNo
|
||||
|
||||
const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
|
||||
kind: 'runtime-test-event',
|
||||
target: 'chat',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start: (_context, match) => ({ event: match.event, view: match.view }),
|
||||
update: context => context.state,
|
||||
publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate',
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined || context.start === undefined) return null
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined || context.start === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'runtime-test-event',
|
||||
kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal'
|
||||
? 'command-input'
|
||||
: context.start.event.type === 'command/run' || context.start.event.type === 'command/done'
|
||||
? 'command'
|
||||
: 'runtime-test-event',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
@@ -271,6 +277,24 @@ describe('live event path', () => {
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
|
||||
const { session } = await opened([])
|
||||
session.handleBlank(true)
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
|
||||
feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
|
||||
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
blank: true,
|
||||
composerPhase: 'active',
|
||||
})
|
||||
expect(session.getSnapshot().chat.order.map(
|
||||
key => session.getSnapshot().chat.nodes.get(key)?.kind,
|
||||
)).toContain('command-input')
|
||||
})
|
||||
|
||||
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
@@ -537,6 +561,21 @@ describe('prompt and cancel errors', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
|
||||
})
|
||||
|
||||
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const result = await session.readAttachment('attachment-1' as never)
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
|
||||
data: Uint8Array.of(0),
|
||||
},
|
||||
})
|
||||
expect(api.callsOf('session.attachment')).toEqual([{
|
||||
sessionId: SID, attachmentId: 'attachment-1',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* deferral — the stage follows list.current), binding identity, breadcrumb
|
||||
* projection, create.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
@@ -35,6 +35,7 @@ type FeedRow = {
|
||||
origin?: 'subagent'
|
||||
running?: boolean
|
||||
blank?: boolean
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
@@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
...(r.origin !== undefined ? { origin: r.origin } : {}),
|
||||
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
await b.svc.refresh()
|
||||
@@ -70,6 +72,38 @@ describe('list store projection', () => {
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
|
||||
|
||||
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
|
||||
// title, running, and blank bits, so an identity guard blind to the preset
|
||||
// would serve the old row forever — and every reader (the hero chip's own
|
||||
// no-op check, the header label) would keep the composition it replaced.
|
||||
b.svc.noteAgentPreset(sid('s1'), 'minimal')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
|
||||
// Every connected client gets this frame; only the switching tab gets the
|
||||
// RPC echo. A client that ignored the payload would keep labelling the
|
||||
// session with the composition it replaced.
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
|
||||
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
bindSettingsScope, SettingsScopeController, type SettingsScope,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
const ENVELOPE = z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}).toJSON()
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function rejected<T>(): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `scope-${rpc++}` as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function view(value: unknown, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'ui-test',
|
||||
schema: ENVELOPE,
|
||||
value,
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function described(value: unknown, revision = 0) {
|
||||
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** Record each distinct published section, starting from the current one. */
|
||||
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
|
||||
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
|
||||
scope.subscribe(() => {
|
||||
const value = scope.getSnapshot().value
|
||||
if (value !== seen[seen.length - 1]) seen.push(value)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('SettingsScopeController', () => {
|
||||
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
})
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
|
||||
.mockResolvedValueOnce(described(null, 5))
|
||||
.mockResolvedValueOnce(described('scalar', 6))
|
||||
.mockResolvedValueOnce(described(['queue'], 7))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const good = trackValues(scope)
|
||||
for (let i = 0; i < 7; i++) await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 7,
|
||||
})
|
||||
expect(good).toEqual([undefined, { preference: 'dark' }])
|
||||
})
|
||||
|
||||
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
|
||||
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const statuses: string[] = []
|
||||
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
|
||||
const stale = scope.load()
|
||||
const fresh = scope.load()
|
||||
await Promise.all([stale, fresh])
|
||||
expect(statuses).not.toContain('unavailable')
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
|
||||
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot().status).toBe('ready')
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('applies a custom decode override in place of the wire schema', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.load()
|
||||
const dark = scope.set('preference', 'dark')
|
||||
const light = scope.set('preference', 'light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
first.resolve(ok(view({ preference: 'dark' }, 5)))
|
||||
await Promise.all([dark, light])
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
|
||||
expect(mutate).toHaveBeenNthCalledWith(1, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
expectedRevision: 4,
|
||||
})
|
||||
expect(mutate).toHaveBeenNthCalledWith(2, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
|
||||
expectedRevision: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.set('preference', 'system')
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await Promise.all([
|
||||
scope.set('preference', 'dark'),
|
||||
scope.set('preference', 'system'),
|
||||
scope.set('preference', 'light'),
|
||||
])
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a subscriber throws', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
let thrown = false
|
||||
scope.subscribe(() => {
|
||||
if (thrown) return
|
||||
thrown = true
|
||||
throw new Error('subscriber failed')
|
||||
})
|
||||
await expect(scope.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(scope.load()).resolves.toBeUndefined()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const describeCall = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
const dark = scope.set('preference', 'dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
const light = scope.set('preference', 'light')
|
||||
let stopped = false
|
||||
const stop = scope.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await scope.set('preference', 'system')
|
||||
await scope.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('keeps a remote browser in memory mode without Host calls', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
'memory',
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
|
||||
})
|
||||
await scope.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindSettingsScope', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.emit('settings/changed', 'unrelated')
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
})
|
||||
await fiber.dispose()
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory mode without starting a settings read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
|
||||
await fiber.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
* contract (double install / not installed / non-root key), store instance
|
||||
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: host/commands-changed
|
||||
* → ctx 'commands/changed'; each established connection generation →
|
||||
* → ctx 'commands/changed'; host/session-preset-changed →
|
||||
* ctx 'session/preset-changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
@@ -67,6 +68,17 @@ describe('wire event bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
|
||||
})
|
||||
expect(seen).toEqual([['s1', 'minimal']])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
@@ -59,7 +59,7 @@ describe('WorkspaceManager', () => {
|
||||
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
|
||||
})
|
||||
|
||||
it('creates by name/path, prepends a new row, and folds failures', async () => {
|
||||
it('creates by path, prepends a new row, and folds failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new WorkspaceManager(api)
|
||||
api.onWorkspaceCreate = payload => Promise.resolve(ok({
|
||||
@@ -67,8 +67,8 @@ describe('WorkspaceManager', () => {
|
||||
created: true,
|
||||
payload,
|
||||
} as never))
|
||||
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
|
||||
await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }])
|
||||
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
|
||||
|
||||
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
|
||||
|
||||
Reference in New Issue
Block a user