Merge commit '70396085b141370ce32de1be4e225b4384eaf46d' into HEAD
# Conflicts: # .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md # .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml # docs/config-catalog.i18n.yaml # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/tool-catalog.i18n.yaml # docs/tool-catalog.md # docs/tool-catalog.zh.md # examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json # examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json # examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json # packages/core/tools/README.i18n.yaml # packages/core/tools/README.zh.md # packages/core/tools/src/code-mode.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/plugin-inventory/tests/inventory.spec.ts # packages/mcp/mcp-client/tests/mcp-client.e2e.ts # packages/mcp/mcp-client/tests/mcp-client.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # packages/test-support/acp-snapshot/README.i18n.yaml # pnpm-lock.yaml
This commit is contained in:
@@ -12,7 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||
import type { HostFrame } from '../src/api/events.ts'
|
||||
import {
|
||||
@@ -110,7 +110,7 @@ async function harness(
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
|
||||
if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('agentPreset.select', () => {
|
||||
// The host-stream opener reads the committed-workspace baseline; this
|
||||
// spec owns preset identity, so the stub suffices (api-proxy-commands
|
||||
// precedent).
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
ctx.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
const abort = new AbortController()
|
||||
const frames: HostFrame[] = []
|
||||
const stream = api.events.host(request({}), abort.signal)
|
||||
|
||||
@@ -12,7 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -24,7 +24,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
@@ -212,13 +212,13 @@ describe('approval pending registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
let api!: ApiProxy
|
||||
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
|
||||
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
|
||||
}, { inject: ['sessions', 'agents', 'userQuestions', 'approval'] }))
|
||||
await fiber.await()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
|
||||
@@ -13,10 +13,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
// Side-effect type imports: the knob-event SessionEventMap merges.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type {} from '@deepseek-ai/dsh-permission-presets'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -31,7 +31,7 @@ function request<P>(payload: P): RpcRequest<P> {
|
||||
async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (session: Session) => void }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
* isolation, and prompt failure mapping.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
PersistenceCoordinator,
|
||||
@@ -39,58 +39,186 @@ function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {
|
||||
}
|
||||
|
||||
describe('sessions.list cold merge', () => {
|
||||
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
||||
it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
||||
const logPath = join(root, 'a.log')
|
||||
writeFileSync(logPath, 'log-bytes')
|
||||
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
|
||||
const smallPath = join(root, 'small.log')
|
||||
const largePath = join(root, 'large.log')
|
||||
writeFileSync(smallPath, 'x'.repeat(1024))
|
||||
writeFileSync(largePath, 'x'.repeat(1025))
|
||||
const metas = [
|
||||
header('session-a', 1000),
|
||||
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
|
||||
header('session-c', 1500),
|
||||
header('small-blank', 100),
|
||||
header('small-conversation', 200),
|
||||
header('large-unknown', 300),
|
||||
header('cached-nonblank', 400),
|
||||
header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
|
||||
header('vanished', 600),
|
||||
header('read-failure', 700),
|
||||
]
|
||||
// Structural fake of the persistence face list() consumes: list + locate.
|
||||
// locate: a real per-session file (mtime wins), a backend without one
|
||||
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
|
||||
// → createdAt).
|
||||
const readFrom = vi.fn(async (id: SessionId) => {
|
||||
if (id === sid('small-blank')) {
|
||||
return {
|
||||
meta: metas[0]!,
|
||||
events: [{ type: 'session/end-seed', seq: 0, time: 700, data: {} }] as SessionEvent[],
|
||||
}
|
||||
}
|
||||
if (id === sid('small-conversation')) {
|
||||
return {
|
||||
meta: metas[1]!,
|
||||
events: [
|
||||
{ type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message', seq: 1, time: 1200,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
] as SessionEvent[],
|
||||
}
|
||||
}
|
||||
if (id === sid('read-failure')) throw new Error('simulated read failure')
|
||||
throw new Error(`unexpected cold read: ${id}`)
|
||||
})
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(metas),
|
||||
locate: (meta: SessionHeader) => {
|
||||
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
|
||||
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
|
||||
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
|
||||
if (meta.id === sid('locationless')) return undefined
|
||||
if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
|
||||
return { kind: 'jsonl', path: smallPath }
|
||||
},
|
||||
readFrom,
|
||||
} as never)
|
||||
ctx.provide('sessionProjectionCache', {
|
||||
cachedSnapshot: (meta: SessionHeader) => {
|
||||
if (meta.id === sid('small-blank')) {
|
||||
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
|
||||
}
|
||||
if (meta.id === sid('small-conversation')) {
|
||||
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } }
|
||||
}
|
||||
if (meta.id === sid('cached-nonblank')) {
|
||||
return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const items = response.result.value.items
|
||||
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
|
||||
const [a, b, c] = items
|
||||
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
|
||||
expect(a?.running).toBe(false)
|
||||
// Cold summaries are never blank: lazy persistence keeps never-appended
|
||||
// sessions out of list(), so a listed session necessarily has events.
|
||||
expect(items.every(item => !item.blank)).toBe(true)
|
||||
expect(a?.cwd).toBe('/proj')
|
||||
expect(a?.parentSessionId).toBeUndefined()
|
||||
expect(b?.updatedAt).toBe(2000)
|
||||
expect(b?.parentSessionId).toBe('session-parent')
|
||||
expect(b?.origin).toBe('subagent')
|
||||
expect(c?.updatedAt).toBe(1500)
|
||||
const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item]))
|
||||
expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
|
||||
// A stale true hint cannot hide the turn found in the bounded read.
|
||||
expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 })
|
||||
expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
|
||||
// false is monotonic, so this row skips stat/read and keeps cached recency.
|
||||
expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
|
||||
expect(byId['locationless']).toMatchObject({
|
||||
blank: false,
|
||||
updatedAt: 500,
|
||||
parentSessionId: 'session-parent',
|
||||
origin: 'subagent',
|
||||
})
|
||||
expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 })
|
||||
expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 })
|
||||
expect(readFrom).toHaveBeenCalledTimes(3)
|
||||
expect(readFrom.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
|
||||
sid('small-blank'),
|
||||
sid('small-conversation'),
|
||||
sid('read-failure'),
|
||||
]))
|
||||
})
|
||||
|
||||
it('can disable bounded blank probes without hiding cold Sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const meta = header('probe-disabled', 100)
|
||||
const readFrom = vi.fn()
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
locate: () => ({ kind: 'jsonl', path: '/not-read' }),
|
||||
readFrom,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
coldBlankProbeMaxBytes: 0,
|
||||
})
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items).toEqual([
|
||||
expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
|
||||
])
|
||||
expect(readFrom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces a probed cold row with the live Session that attached during the read', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const meta = header('attached-during-probe', 100)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
|
||||
const path = join(root, 'small.log')
|
||||
writeFileSync(path, 'x')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
locate: () => ({ kind: 'jsonl', path }),
|
||||
readFrom: async () => {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
return {
|
||||
meta,
|
||||
events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[],
|
||||
}
|
||||
},
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const listing = api.sessions.list(request({}))
|
||||
await started.promise
|
||||
const session = ctx.sessions.create(meta.id, {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message', seq: 1, time: 300,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
createdAt: meta.createdAt,
|
||||
},
|
||||
})
|
||||
ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
|
||||
release.resolve(undefined)
|
||||
|
||||
const response = await listing
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
expect(response.result.value.items).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: meta.id,
|
||||
blank: false,
|
||||
running: true,
|
||||
updatedAt: 300,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('attached updatedAt excludes end-seed', () => {
|
||||
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
|
||||
describe('attached updatedAt tracks human prompts', () => {
|
||||
it('ignores pickup and non-prompt work after the latest human message', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
@@ -99,7 +227,12 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{
|
||||
type: 'user/message', seq: 1, time: worked,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'turn/end', seq: 2, time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
meta: { cwd: '/proj', createdAt: 500 },
|
||||
})
|
||||
@@ -113,12 +246,21 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
expect(summary?.updatedAt).toBe(worked)
|
||||
|
||||
// Real work appended after end-seed does move it.
|
||||
// A lifecycle boundary is not a human update.
|
||||
resumed.append('turn/start', { turn: 2 })
|
||||
const afterBoundary = await api.sessions.list(request({}))
|
||||
if (!afterBoundary.result.ok) throw new Error('list failed')
|
||||
expect(afterBoundary.result.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt)
|
||||
.toBe(worked)
|
||||
|
||||
const prompt = resumed.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'new prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const after = await api.sessions.list(request({}))
|
||||
if (!after.result.ok) throw new Error('list failed')
|
||||
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
expect(moved?.updatedAt).toBeGreaterThan(worked)
|
||||
expect(moved?.updatedAt).toBe(prompt.time)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,7 +268,7 @@ describe('cold history recovery view', () => {
|
||||
it('shows in-memory interruption repair without activating the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const sessionId = sid('session-interrupted')
|
||||
const meta = header(sessionId, 1000)
|
||||
const stored: StoredPrefix<never> = {
|
||||
@@ -188,7 +330,7 @@ describe('Remote Agent and Session lookup policy', () => {
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const sessionId = sid('session-remote-cold')
|
||||
const meta = header(sessionId, 1000)
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
|
||||
@@ -230,7 +372,7 @@ describe('Remote Agent and Session lookup policy', () => {
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const coldId = sid('session-remote-cold-child')
|
||||
const coldMeta = header(coldId, 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
@@ -267,9 +409,9 @@ describe('Remote Agent and Session lookup policy', () => {
|
||||
|
||||
const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
|
||||
const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
|
||||
await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure)
|
||||
await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
|
||||
await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure)
|
||||
await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
@@ -281,7 +423,7 @@ describe('subagent ownership fence', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const sessionId = sid('session-child')
|
||||
const meta = header('session-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
@@ -346,7 +488,7 @@ describe('subagent ownership fence', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const sessionId = sid('session-legacy-child')
|
||||
const meta = header('session-legacy-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
@@ -387,7 +529,7 @@ describe('subagent ownership fence', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
|
||||
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(parent)
|
||||
@@ -445,7 +587,7 @@ describe('subagent ownership fence', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
|
||||
seed: [{
|
||||
type: 'subagent/descriptor',
|
||||
@@ -473,7 +615,7 @@ describe('subagent ownership fence', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
@@ -549,7 +691,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
@@ -569,7 +711,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const inspect = vi.fn()
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([]),
|
||||
@@ -589,7 +731,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const session = ctx.sessions.create(sid('session-throwing'))
|
||||
// A live structural stub whose delivery verbs throw synchronously, the
|
||||
// shape a disposed loop presents at this gateway boundary.
|
||||
@@ -622,7 +764,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const sessionId = sid('race-resume')
|
||||
const meta: SessionHeader = header('race-resume', 1000)
|
||||
ctx.provide('sessionPersistence', {
|
||||
|
||||
@@ -11,13 +11,13 @@ import z from '@deepseek-ai/schemastery'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Settings, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { Credentials } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
@@ -45,10 +45,10 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
|
||||
}
|
||||
|
||||
/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
|
||||
class MemorySettings extends Settings {
|
||||
class MemorySettings extends SettingsProvider {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
|
||||
constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: {
|
||||
doc?: Record<string, unknown>
|
||||
readOnly?: boolean
|
||||
documentPath?: string
|
||||
@@ -88,10 +88,10 @@ class MemorySettings extends Settings {
|
||||
}
|
||||
|
||||
/** In-memory credential provider with an env-shadow double for the rejection path. */
|
||||
class MemoryCredentials extends Credentials {
|
||||
class MemoryCredentials extends CredentialProvider {
|
||||
private readonly values = new Map<string, string>()
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Credentials>[0], options?: { shadowed?: string[] }) {
|
||||
constructor(ctx: ConstructorParameters<typeof CredentialProvider>[0], options?: { shadowed?: string[] }) {
|
||||
super(ctx)
|
||||
this.shadowed = new Set(options?.shadowed ?? [])
|
||||
}
|
||||
@@ -177,10 +177,10 @@ async function harness(options?: {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
|
||||
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
|
||||
// Model-provider namespaces plus the explicit Web preference and product
|
||||
@@ -192,7 +192,7 @@ async function harness(options?: {
|
||||
}
|
||||
// Host-stream opener reads the committed-workspace baseline; the stub
|
||||
// suffices — the real workspace composition is api-proxy-workspace.spec's.
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
ctx.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('settings domain', () => {
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.settings.describe(request({})))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('dsh-settings-local')
|
||||
expect(error.message).toContain('dsh-settings-file')
|
||||
})
|
||||
|
||||
it('describes layered redacted namespaces with their secret slots', async () => {
|
||||
@@ -344,7 +344,7 @@ describe('settings domain', () => {
|
||||
ctx.settings.register(settingsNamespace('ui-conversation'), z.object({
|
||||
busyEnter: z.union(['queue', 'steer']).default('queue'),
|
||||
}))
|
||||
ctx.settings.register(settingsNamespace('bash'), z.object({
|
||||
ctx.settings.register(settingsNamespace('shell'), z.object({
|
||||
timeoutMs: z.number().default(120_000),
|
||||
}))
|
||||
ctx.settings.register(settingsNamespace('agent-loop'), z.object({
|
||||
@@ -358,7 +358,7 @@ describe('settings domain', () => {
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual([
|
||||
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
|
||||
'bash', 'agent-loop', 'web-search-deepseek',
|
||||
'shell', 'agent-loop', 'web-search-deepseek',
|
||||
])
|
||||
const permission = expectOk(await api.settings.mutate(request({
|
||||
ns: 'permission',
|
||||
@@ -381,7 +381,7 @@ describe('settings domain', () => {
|
||||
})))
|
||||
expect(conversation.value).toEqual({ busyEnter: 'steer' })
|
||||
const bash = expectOk(await api.settings.mutate(request({
|
||||
ns: 'bash',
|
||||
ns: 'shell',
|
||||
ops: [{ op: 'set', path: ['timeoutMs'], value: 5_000 }],
|
||||
})))
|
||||
expect(bash.value).toEqual({ timeoutMs: 5_000 })
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
@@ -27,8 +27,8 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('workspace', { list: () => workspaces } as never)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
|
||||
ctx.agents.setFactory({
|
||||
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
|
||||
const session = ctx.sessions.create(options.sessionId, {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* baseline is sent only for a session that has tasks, every registry change
|
||||
* pushes that owner's whole set, an unowned change fans out to every
|
||||
* subscribed session, the projection drops the three internal snapshot
|
||||
* fields, a composition without `ctx.tasks` emits nothing, and listing never
|
||||
* fields, a composition without `ctx.jobs` emits nothing, and listing never
|
||||
* resumes a cold session.
|
||||
*/
|
||||
|
||||
@@ -13,14 +13,14 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
|
||||
type JobFrame = Extract<MuxFrame, { type: 'session/jobs' }>
|
||||
|
||||
/**
|
||||
* A producer whose settlement the test drives. `cancel` deliberately does not
|
||||
@@ -28,7 +28,7 @@ type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
|
||||
* test supplies the terminal outcome and its detail.
|
||||
*/
|
||||
function producer(label = 'sleep 60') {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
let settle!: (outcome: JobOutcome) => void
|
||||
// A stream producer, so the carrier CAN consume the cursor if it ever calls
|
||||
// `read()`; `reads` is what proves it never does.
|
||||
const reads = { count: 0 }
|
||||
@@ -37,21 +37,21 @@ function producer(label = 'sleep 60') {
|
||||
label,
|
||||
run: () => ({
|
||||
cancel: () => {},
|
||||
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
|
||||
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
|
||||
readOutput: () => { reads.count += 1; return 'stolen output' },
|
||||
}),
|
||||
}
|
||||
return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } }
|
||||
return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } }
|
||||
}
|
||||
|
||||
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) {
|
||||
await ctx.plugin(LocalTaskService)
|
||||
ctx.tasks.attachController('api-proxy-test')
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
ctx.jobs.attachController('api-proxy-test')
|
||||
}
|
||||
const session = ctx.sessions.create()
|
||||
const agent = {
|
||||
@@ -67,21 +67,21 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
|
||||
/** Drain the mux until `count` session/jobs frames arrived, then abort. */
|
||||
async function collect(
|
||||
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
count: number,
|
||||
abort: AbortController,
|
||||
): Promise<TaskFrame[]> {
|
||||
): Promise<JobFrame[]> {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const envelope of iterable) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort()
|
||||
if (frames.filter(frame => frame.type === 'session/jobs').length >= count) abort.abort()
|
||||
}
|
||||
return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks')
|
||||
return frames.filter((frame): frame is JobFrame => frame.type === 'session/jobs')
|
||||
}
|
||||
|
||||
describe('session/tasks subscription baseline', () => {
|
||||
describe('session/jobs subscription baseline', () => {
|
||||
it('is omitted for a session with no tasks — absence is the empty set', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const abort = new AbortController()
|
||||
@@ -94,22 +94,22 @@ describe('session/tasks subscription baseline', () => {
|
||||
}
|
||||
})()
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
expect(frames.some(frame => frame.type === 'session/jobs')).toBe(false)
|
||||
expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true)
|
||||
void session
|
||||
})
|
||||
|
||||
it('carries the live set for a session that already has tasks when the stream opens', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent })
|
||||
ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent })
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal)
|
||||
const [baseline] = await collect(stream, 1, abort)
|
||||
expect(baseline?.sessionId).toBe(session.id)
|
||||
expect(baseline?.tasks).toHaveLength(1)
|
||||
const [task] = baseline?.tasks ?? []
|
||||
expect(task?.startedAt).toBeTypeOf('number')
|
||||
expect({ ...task, startedAt: 0 }).toEqual({
|
||||
expect(baseline?.jobs).toHaveLength(1)
|
||||
const [job] = baseline?.jobs ?? []
|
||||
expect(job?.startedAt).toBeTypeOf('number')
|
||||
expect({ ...job, startedAt: 0 }).toEqual({
|
||||
id: 'bash-1',
|
||||
kind: 'bash',
|
||||
label: 'pnpm run build',
|
||||
@@ -119,7 +119,7 @@ describe('session/tasks subscription baseline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks change pushes', () => {
|
||||
describe('session/jobs change pushes', () => {
|
||||
it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
@@ -128,16 +128,16 @@ describe('session/tasks change pushes', () => {
|
||||
const collected = collect(stream, 3, abort)
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.tasks.kill(id, agent, 'test')
|
||||
const id = ctx.jobs.start({ ...p.spec, owner: agent })
|
||||
ctx.jobs.kill(id, agent, 'test')
|
||||
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
|
||||
expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed'])
|
||||
expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed'])
|
||||
// Terminal detail rides the same whole-set push; no separate signal.
|
||||
expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM')
|
||||
expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number')
|
||||
expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM')
|
||||
expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => {
|
||||
@@ -146,10 +146,10 @@ describe('session/tasks change pushes', () => {
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
|
||||
ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
|
||||
|
||||
const [frame] = await collected
|
||||
const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {})
|
||||
const fields: readonly string[] = Object.keys(frame?.jobs[0] ?? {})
|
||||
expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status'])
|
||||
})
|
||||
|
||||
@@ -161,12 +161,12 @@ describe('session/tasks change pushes', () => {
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 2, abort)
|
||||
|
||||
ctx.tasks.start(producer('open to every caller').spec)
|
||||
ctx.jobs.start(producer('open to every caller').spec)
|
||||
|
||||
const frames = await collected
|
||||
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
|
||||
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
|
||||
for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller')
|
||||
for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller')
|
||||
})
|
||||
|
||||
it('serves a cold session the unowned set without resuming it', async () => {
|
||||
@@ -183,14 +183,14 @@ describe('session/tasks change pushes', () => {
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
ctx.jobs.start(producer().spec)
|
||||
await collected
|
||||
expect(loaded).toBe(false)
|
||||
expect(ctx.agents.get(coldId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks without the registry', () => {
|
||||
describe('session/jobs without the registry', () => {
|
||||
it('emits no frames at all, so the client renders no entry point', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
const proxy = api(ctx)
|
||||
@@ -205,14 +205,14 @@ describe('session/tasks without the registry', () => {
|
||||
})()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
expect(frames.some(frame => frame.type === 'session/jobs')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks never consumes model output', () => {
|
||||
describe('session/jobs never consumes model output', () => {
|
||||
it('drives the whole lifecycle without calling the single consuming cursor', async () => {
|
||||
// `ctx.tasks.read()` consumes the one output cursor, so a carrier read
|
||||
// silently takes bytes the model's `task_output` will never see. The
|
||||
// `ctx.jobs.read()` consumes the one output cursor, so a carrier read
|
||||
// silently takes bytes the model's `job_output` will never see. The
|
||||
// failure is invisible at the call site, which is why this asserts the
|
||||
// count rather than trusting review.
|
||||
const { ctx, agent } = await harness(true)
|
||||
@@ -222,8 +222,8 @@ describe('session/tasks never consumes model output', () => {
|
||||
const collected = collect(stream, 3, abort)
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.tasks.kill(id, agent, 'test')
|
||||
const id = ctx.jobs.start({ ...p.spec, owner: agent })
|
||||
ctx.jobs.kill(id, agent, 'test')
|
||||
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
await collected
|
||||
|
||||
@@ -233,18 +233,18 @@ describe('session/tasks never consumes model output', () => {
|
||||
it('reads nothing while minting the subscription baseline either', async () => {
|
||||
const { ctx, agent } = await harness(true)
|
||||
const p = producer()
|
||||
ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.jobs.start({ ...p.spec, owner: agent })
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal)
|
||||
const [baseline] = await collect(stream, 1, abort)
|
||||
|
||||
expect(baseline?.tasks).toHaveLength(1)
|
||||
expect(baseline?.jobs).toHaveLength(1)
|
||||
expect(p.reads.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks baseline for a session born after the stream opened', () => {
|
||||
describe('session/jobs baseline for a session born after the stream opened', () => {
|
||||
it('carries the already-visible unowned set to the new session', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
@@ -253,11 +253,11 @@ describe('session/tasks baseline for a session born after the stream opened', ()
|
||||
|
||||
// One unowned task exists before the new session is created; the subscribe
|
||||
// frame clears the client mirror, so the baseline has to follow it.
|
||||
ctx.tasks.start(producer('visible to every caller').spec)
|
||||
ctx.jobs.start(producer('visible to every caller').spec)
|
||||
const created = ctx.sessions.create()
|
||||
|
||||
const frames = await collect(stream, 2, abort)
|
||||
const forNew = frames.filter(frame => frame.sessionId === created.id)
|
||||
expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller')
|
||||
expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AttachmentStore from '@deepseek-ai/dsh-attachment'
|
||||
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
||||
LlmResolvedModelInfo, StreamChunk,
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
@@ -85,8 +85,8 @@ async function harness(logged?: {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
|
||||
{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* pushed to mux consumers as a session/projection frame minted here.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
@@ -18,7 +18,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -50,7 +50,7 @@ const lastUserUnit = (): ProjectionDefinition<'test/last-user', LastUserState> =
|
||||
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
|
||||
const session = ctx.sessions.create()
|
||||
@@ -163,10 +163,29 @@ describe('session.history projections block', () => {
|
||||
dispose()
|
||||
const after = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||
if (!after.result.ok) throw new Error('unreachable')
|
||||
// The registry is still mounted, so the block itself stays (asOfSeq cut
|
||||
// with zero keys); the disposed key reads as capability absence.
|
||||
// The registry stays mounted; only the disposed key leaves while the
|
||||
// gateway-owned Session-list unit remains.
|
||||
expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
expect(after.result.value.projections?.values).toEqual({})
|
||||
expect('test/last-user' in (after.result.value.projections?.values ?? {})).toBe(false)
|
||||
expect(after.result.value.projections?.values.sessionListMetadata).toEqual({
|
||||
blank: true,
|
||||
lastPromptAt: session.events.at(-1)?.time,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => {
|
||||
createApiProxy(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
}, { inject: ['sessions', 'agents', 'userQuestions', 'sessionProjections'] }))
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.sessionProjections.snapshot(session).values.sessionListMetadata)
|
||||
.toEqual({ blank: true, lastPromptAt: null })
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,11 +193,18 @@ describe('session.list projections column', () => {
|
||||
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
const gateway = api(ctx)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
const response = await gateway.sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
||||
expect(row?.projections?.values.sessionListMetadata).toEqual({
|
||||
blank: false,
|
||||
lastPromptAt: session.events.at(-1)?.time,
|
||||
})
|
||||
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
@@ -266,21 +292,33 @@ describe('session/projection push frame', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 2, abort)
|
||||
const collected = collect(stream, 5, abort)
|
||||
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(100)
|
||||
seedMessages(session, 1)
|
||||
// Same-reference apply: turn/start does not concern the unit — no frame.
|
||||
now.mockReturnValue(200)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
now.mockReturnValue(300)
|
||||
seedMessages(session, 1)
|
||||
now.mockRestore()
|
||||
|
||||
const frames = await collected
|
||||
const pushes = frames.filter(
|
||||
(f): f is Extract<MuxFrame, { type: 'session/projection' }> => f.type === 'session/projection',
|
||||
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
|
||||
f.type === 'session/projection' && f.key === 'test/last-user',
|
||||
)
|
||||
expect(pushes).toEqual([
|
||||
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
|
||||
])
|
||||
expect(frames.filter(
|
||||
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
|
||||
f.type === 'session/projection' && f.key === 'sessionListMetadata',
|
||||
)).toEqual([
|
||||
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 },
|
||||
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 },
|
||||
])
|
||||
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
|
||||
const tail = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||
if (!tail.result.ok) throw new Error('unreachable')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
@@ -11,7 +11,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
|
||||
@@ -74,7 +74,7 @@ describe('question response validation', () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
const asked = ctx.userQuestions.ask({
|
||||
agent: agent(ctx),
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
@@ -98,7 +98,7 @@ describe('question response validation', () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
const asked = ctx.userQuestions.ask({
|
||||
agent: agent(ctx),
|
||||
questions: [{
|
||||
id: 'target',
|
||||
|
||||
@@ -14,7 +14,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
@@ -31,7 +31,7 @@ async function composed(withTitles = true): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
if (withTitles) {
|
||||
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 })
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import {
|
||||
SessionQueryError,
|
||||
type SessionSearchHit,
|
||||
@@ -63,7 +63,7 @@ async function baseContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -95,8 +95,13 @@ function bench(options: {
|
||||
})
|
||||
// The gateway's own projection push feed subscribes at construction; the
|
||||
// no-op disposer keeps that feed quiet while these tests pin history reads.
|
||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
ctx.provide('sessionProjections', {
|
||||
snapshot,
|
||||
restore,
|
||||
onChanged: () => () => {},
|
||||
register: () => () => {},
|
||||
})
|
||||
ctx.provide('userQuestions', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
|
||||
})
|
||||
|
||||
@@ -13,12 +13,12 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -68,8 +68,8 @@ async function harness(): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.tools.register(tool('gen', {
|
||||
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
||||
@@ -249,7 +249,7 @@ describe('mux live view computation', () => {
|
||||
const shadowed = [...session.surface.nodes]
|
||||
// A compaction transaction: a log-only summary record immediately followed by the
|
||||
// replacement that shadows the range.
|
||||
const summary = appendExtension(session, 'compact/summary', {
|
||||
const summary = appendExtension(session, 'compaction/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
|
||||
shadowedSeqs: shadowed,
|
||||
|
||||
@@ -9,7 +9,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
|
||||
@@ -52,7 +52,7 @@ function stubAgent(session: Session): Agent {
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
runMaintenance: job => job(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ async function harness(
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
@@ -327,7 +327,7 @@ describe('workspace.insertBefore', () => {
|
||||
const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace
|
||||
|
||||
const abort = new AbortController()
|
||||
const listWorkspaces = vi.spyOn(ctx.workspace, 'list')
|
||||
const listWorkspaces = vi.spyOn(ctx.workspaceRegistry, 'list')
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
expect(listWorkspaces).toHaveBeenCalledTimes(1)
|
||||
@@ -394,7 +394,7 @@ describe('session creation and Workspace membership', () => {
|
||||
it('retains a published session when attachment fails and repairs it on retry', async () => {
|
||||
const { api, ctx, root } = await harness()
|
||||
const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
|
||||
const workspace = ctx.workspace.list()[0]
|
||||
const workspace = ctx.workspaceRegistry.list()[0]
|
||||
if (workspace === undefined) throw new Error('workspace missing from registry')
|
||||
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
|
||||
const sessionId = SessionId('session-attach-retry')
|
||||
|
||||
@@ -453,8 +453,8 @@ describe('events frame schemas', () => {
|
||||
},
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [] },
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 },
|
||||
{ id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 },
|
||||
] },
|
||||
@@ -468,12 +468,12 @@ describe('events frame schemas', () => {
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
|
||||
// A producer kind stays an open string, but the closed status set and
|
||||
// the identity/label bounds are the carrier's own wire contract.
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
|
||||
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
|
||||
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
|
||||
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -77,7 +77,7 @@ async function buildApi(
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const query = services.query ?? true
|
||||
const persistence = services.persistence ?? true
|
||||
if (query) {
|
||||
@@ -127,17 +127,32 @@ async function responseBytes(response: Response): Promise<Uint8Array> {
|
||||
|
||||
describe('session export compression config', () => {
|
||||
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
|
||||
expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 })
|
||||
expect(ApiProxyService.Config({})).toEqual({
|
||||
sessionExportCompressionLevel: 6,
|
||||
coldBlankProbeMaxBytes: 1024,
|
||||
})
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 0 })
|
||||
.toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 9 })
|
||||
.toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 })
|
||||
for (const value of [-1, 10, 1.5]) {
|
||||
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cold blank probe config', () => {
|
||||
it('accepts a per-Session byte bound including zero and rejects invalid bounds', () => {
|
||||
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 0 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0 })
|
||||
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 2048 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048 })
|
||||
for (const value of [-1, 1.5]) {
|
||||
expect(() => ApiProxyService.Config({ coldBlankProbeMaxBytes: value })).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.export download endpoint', () => {
|
||||
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
|
||||
Reference in New Issue
Block a user