Merge branch 'master' of https://github.com/deepseek-harness/deepseek-harness into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/session/README.i18n.yaml # packages/core/session/src/types.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/llm/llm/tests/service.spec.ts # packages/sdk/sdk-client/README.i18n.yaml # packages/sdk/sdk-protocol/README.i18n.yaml # packages/sdk/sdk-protocol/README.md # packages/sdk/sdk-protocol/README.zh.md # packages/subagent/subagent-dsh-sdk/README.i18n.yaml # packages/ui/jsonrpc/README.i18n.yaml # packages/ui/jsonrpc/README.md # packages/ui/jsonrpc/README.zh.md # packages/ui/tui/src/index.ts # python/sdk/README.i18n.yaml # scripts/gen-cordis-catalog.ts
This commit is contained in:
@@ -62,8 +62,9 @@ export class FakeApiClient implements IApiClient {
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
@@ -71,7 +72,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: this.defaultModel,
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
|
||||
}],
|
||||
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
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)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
@@ -181,6 +183,24 @@ export class FakeApiClient implements IApiClient {
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
}
|
||||
|
||||
readonly credentials: IApiClient['credentials'] = {
|
||||
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
|
||||
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
|
||||
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
|
||||
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -277,6 +277,23 @@ describe('remaining branches', () => {
|
||||
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it('reconciles a fork child published before workspace attachment fails', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onFork = () => Promise.resolve(err({
|
||||
code: 'workspace-attach-failed',
|
||||
message: 'forked but unattached',
|
||||
details: { sessionId: S2, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api)
|
||||
const result = await manager.fork({ sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
|
||||
sessionId: S2,
|
||||
parentSessionId: S1,
|
||||
blank: false,
|
||||
})])
|
||||
})
|
||||
|
||||
it('reconciles a preallocated id after an ordinary transport failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
@@ -361,7 +378,7 @@ describe('connected generation', () => {
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const openedSession = manager.get(S1)
|
||||
|
||||
@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
|
||||
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
|
||||
})
|
||||
|
||||
it('continues from a materialized history prefix', () => {
|
||||
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
|
||||
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
|
||||
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
|
||||
})
|
||||
|
||||
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
|
||||
const acc = new PartialAccumulator(1, 0)
|
||||
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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'
|
||||
@@ -7,6 +7,10 @@ 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 }))
|
||||
}
|
||||
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
|
||||
.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.loadAll()
|
||||
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
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('open', () => {
|
||||
gate.resolve(ok({
|
||||
events: entries(page) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await opening
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
@@ -135,7 +135,7 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().composerPhase).toBe('blank')
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
|
||||
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
|
||||
feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
@@ -255,7 +255,7 @@ describe('paging', () => {
|
||||
gate.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await Promise.all([first, second])
|
||||
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
|
||||
@@ -571,7 +571,7 @@ describe('remaining branches', () => {
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'stale' },
|
||||
})) // success, but its generation is gone
|
||||
await Promise.all([opening, resynced])
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
|
||||
@@ -594,7 +594,7 @@ describe('remaining branches', () => {
|
||||
secondPull.resolve(ok({
|
||||
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'stale' },
|
||||
}))
|
||||
await Promise.all([opening, resynced])
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
@@ -612,7 +612,7 @@ describe('remaining branches', () => {
|
||||
repairPull.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'stale' },
|
||||
})) // repair result: stale, dropped
|
||||
await resynced
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
@@ -657,7 +657,7 @@ describe('remaining branches', () => {
|
||||
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
|
||||
] as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Context } from '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'
|
||||
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
@@ -399,6 +399,69 @@ describe('create', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('fork', () => {
|
||||
it.each([
|
||||
['Roadmap', 'Roadmap (1)'],
|
||||
['Roadmap (1)', 'Roadmap (2)'],
|
||||
['计划(1)', '计划(2)'],
|
||||
['计划 (9)', '计划 (10)'],
|
||||
])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
|
||||
const b = bench()
|
||||
b.svc.handleMuxEnvelope({
|
||||
rpcId: 'source-title' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never,
|
||||
})
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
b.api.onRename = (payload) => {
|
||||
const { title } = payload as { title: string }
|
||||
return Promise.resolve(ok({ title, seq: 3 }))
|
||||
}
|
||||
|
||||
await expect(b.svc.fork({
|
||||
sessionId: sid('source'), atSeq: 7, increaseTitle: true,
|
||||
})).resolves.toBe('child')
|
||||
|
||||
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
|
||||
expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
|
||||
title: childTitle,
|
||||
displayTitle: childTitle,
|
||||
parentId: 'source',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not rename without the title policy or a durable source title', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
|
||||
expect(b.api.callsOf('session.rename')).toEqual([])
|
||||
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
|
||||
await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
|
||||
expect(b.api.callsOf('session.rename')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects when child rename fails while keeping the published child addressable', async () => {
|
||||
const b = bench()
|
||||
b.svc.handleMuxEnvelope({
|
||||
rpcId: 'source-title' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never,
|
||||
})
|
||||
await feedList(b, [{ id: 'source' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
b.api.onRename = () => Promise.resolve(err({
|
||||
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
|
||||
}))
|
||||
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
|
||||
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
|
||||
expect(b.svc.binding(sid('child'))).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
|
||||
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -44,6 +44,22 @@ describe('wire event bridge', () => {
|
||||
expect(changed).toBe(1)
|
||||
})
|
||||
|
||||
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
|
||||
const bench = await mount()
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
|
||||
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
|
||||
bench.ctx.on('models/changed', () => seen.push(['models']))
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
|
||||
expect(seen).toEqual([
|
||||
['settings', 'llm-pi-ai'],
|
||||
['credentials', 'OPENAI_API_KEY'],
|
||||
['models'],
|
||||
])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
|
||||
Reference in New Issue
Block a user