mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-07 04:10:42 +00:00
# Conflicts: # .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml # .agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml # .agents/notes/implemented/architecture/2026-06-14-session-persistence.md # .agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md # .agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml # .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md # .agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md # .agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml # .agents/notes/implemented/architecture/2026-08-05-session-preparation.md # .agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md # .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml # .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md # .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md # .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml # .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md # .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md # .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md # .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md # .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml # .agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml # .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md # .agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md # apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts # apps/web/tests/cold-blank-session.e2e.ts # apps/web/tests/message-actions.e2e.ts # apps/web/tests/scaffold.ts # apps/web/tests/workspace-management.e2e.ts # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/persistence-catalog.i18n.yaml # docs/persistence-catalog.md # docs/subsystems/feedback.i18n.yaml # docs/subsystems/feedback.md # docs/subsystems/feedback.zh.md # docs/subsystems/persistence.i18n.yaml # docs/subsystems/persistence.md # docs/subsystems/persistence.zh.md # packages/acp/acp/src/index.ts # packages/acp/acp/tests/bridge.spec.ts # packages/api/session-controller/src/list.ts # packages/api/session-controller/tests/agent.host.spec.ts # packages/api/session-controller/tests/session-cold.host.spec.ts # packages/api/session-controller/tests/session-projections.host.spec.ts # packages/api/session-controller/tests/session-search.host.spec.ts # packages/api/session-controller/tests/test-remote.ts # packages/api/session-controller/tests/transport.host.spec.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/extensions/tool-cordis/src/api-catalog.ts # packages/feedback/message-feedback/src/index.ts # packages/feedback/message-feedback/tests/helpers.ts # packages/feedback/message-feedback/tests/message-feedback.spec.ts # packages/session-query/session-log-export/src/archive.ts # packages/session-query/session-log-export/tests/archive.host.spec.ts # packages/session-query/session-log-export/tests/route.host.spec.ts # packages/session-query/session-query-sqlite/README.i18n.yaml # packages/session-query/session-query-sqlite/README.md # packages/session-query/session-query-sqlite/README.zh.md # packages/session-query/session-query-sqlite/src/index.ts # packages/session-query/session-query-sqlite/tests/sqlite.spec.ts # packages/session-query/session-query/README.i18n.yaml # packages/session-query/session-query/README.md # packages/session-query/session-query/README.zh.md # packages/session-query/session-query/src/corpus.ts # packages/session-query/session-query/tests/observation.spec.ts # packages/session-query/session-query/tests/session-query.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts # packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts # packages/session/session-persistence-jsonl/README.i18n.yaml # packages/session/session-persistence-jsonl/README.md # packages/session/session-persistence-jsonl/README.zh.md # packages/session/session-persistence-jsonl/src/index.ts # packages/session/session-persistence-jsonl/tests/jsonl.spec.ts # packages/session/session-persistence-jsonl/tests/zstd.spec.ts # packages/session/session-persistence/README.i18n.yaml # packages/session/session-persistence/README.md # packages/session/session-persistence/README.zh.md # packages/session/session-persistence/src/coordinator.ts # packages/session/session-persistence/src/index.ts # packages/session/session-persistence/tests/contract.ts # packages/session/session-persistence/tests/coordinator-contract.ts # packages/session/session-persistence/tests/persistence.spec.ts # packages/session/session-telemetry/package.json # packages/shell/tool-bash/tests/tools.spec.ts # packages/subagent/subagent/src/continuation.ts # packages/subagent/subagent/tests/list-children.spec.ts # packages/workspace/workspace/src/index.ts # packages/workspace/workspace/tests/workspace.spec.ts # scripts/gen-cordis-catalog.ts
219 lines
8.5 KiB
TypeScript
219 lines
8.5 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
|
|
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import SessionController from '../src/index.ts'
|
|
import type { ApiSessionAgentController } from '../src/agent.ts'
|
|
import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
|
|
|
|
const defaults = {
|
|
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
|
|
cwd: '/tmp',
|
|
}
|
|
|
|
describe('SessionController facade', () => {
|
|
it('does not require the Tools service', () => {
|
|
expect(SessionController.inject).not.toContain('tools')
|
|
})
|
|
|
|
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
const sessionId = SessionId('controller-session')
|
|
const header: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: sessionId,
|
|
createdAt: 1,
|
|
cwd: '/workspace',
|
|
isSeeded: false,
|
|
}
|
|
const events: SessionEvent[] = []
|
|
const inspect = vi.fn(() => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events,
|
|
}))
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect,
|
|
}) as never)
|
|
const controller = createSessionTestController(ctx, defaults)
|
|
const status = vi.fn()
|
|
const failure = vi.fn()
|
|
const activity = vi.fn()
|
|
ctx.on('api-session/status', status)
|
|
ctx.on('api-session/error', failure)
|
|
ctx.on('api-session/activity', activity)
|
|
|
|
await expect(controller.inspect(sessionId)).resolves.toEqual({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events,
|
|
})
|
|
expect(inspect).toHaveBeenCalledOnce()
|
|
|
|
const session = ctx.sessions.create(sessionId, { meta: header })
|
|
const agent = {
|
|
id: sessionId,
|
|
session,
|
|
status: 'idle',
|
|
ctx,
|
|
} as Agent
|
|
ctx.agents.register(agent)
|
|
const consumeSelection = vi.spyOn(
|
|
(controller as unknown as { agents: ApiSessionAgentController }).agents,
|
|
'consumeSelection',
|
|
)
|
|
|
|
await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
|
|
await expect(controller.inspect(sessionId)).resolves.toEqual({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events,
|
|
})
|
|
expect(inspect).toHaveBeenCalledOnce()
|
|
ctx.emit('agent/status', { agent, status: 'running' })
|
|
ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') })
|
|
session.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
source: { kind: 'user' },
|
|
}), { surfaceOp: 'append' })
|
|
expect(status).toHaveBeenCalledWith(sessionId, true)
|
|
expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
|
|
expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
|
|
session.append('request/header', {
|
|
header: { config: { provider: 'fixture', model: 'fixture-model' } },
|
|
reason: 'initial',
|
|
})
|
|
expect(consumeSelection).toHaveBeenCalledWith(
|
|
agent, 'fixture', 'fixture-model', undefined,
|
|
)
|
|
const unowned = ctx.sessions.create(SessionId('controller-unowned'), {
|
|
meta: { cwd: '/workspace' },
|
|
})
|
|
unowned.append('request/header', {
|
|
header: { config: { provider: 'fixture', model: 'other-model' } },
|
|
reason: 'initial',
|
|
})
|
|
expect(consumeSelection).toHaveBeenCalledTimes(1)
|
|
|
|
const abort = new AbortController()
|
|
const iterator = controller.follow({
|
|
address: { kind: 'session', sessionId },
|
|
}, abort.signal)[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toMatchObject({
|
|
done: false,
|
|
value: { type: 'snapshot', cursor: 1 },
|
|
})
|
|
abort.abort()
|
|
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
|
|
it.each(['success', 'domain-error', 'throw'] as const)(
|
|
'promotes a prepared follow observation in the background: %s',
|
|
async (outcome) => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
const sessionId = SessionId(`background-${outcome}`)
|
|
const header: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false,
|
|
}
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect: () => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events: [],
|
|
}),
|
|
}) as never)
|
|
const controller = createSessionTestController(ctx, defaults)
|
|
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
|
|
const apiError = vi.fn()
|
|
ctx.on('api-session/error', apiError)
|
|
const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
|
|
const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent
|
|
const resolve = vi.spyOn(agents, 'resolveObservedAgent')
|
|
if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
|
|
else if (outcome === 'domain-error') {
|
|
resolve.mockResolvedValue({
|
|
error: new RemoteError('gateway/internal', 'activation unavailable', {}),
|
|
})
|
|
} else {
|
|
resolve.mockRejectedValue(new Error('activation crashed'))
|
|
}
|
|
const abort = new AbortController()
|
|
const iterator = controller.follow({
|
|
address: { kind: 'session', sessionId },
|
|
}, abort.signal)[Symbol.asyncIterator]()
|
|
|
|
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
|
|
const waiting = iterator.next()
|
|
await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() })
|
|
if (outcome === 'domain-error') {
|
|
await vi.waitFor(() => {
|
|
expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable')
|
|
})
|
|
} else if (outcome === 'throw') {
|
|
await vi.waitFor(() => {
|
|
expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed'))
|
|
})
|
|
} else {
|
|
expect(apiError).not.toHaveBeenCalled()
|
|
}
|
|
abort.abort()
|
|
await expect(waiting).resolves.toMatchObject({ done: true })
|
|
await ctx.fiber.dispose()
|
|
},
|
|
)
|
|
|
|
it('waits for an admitted background promotion during teardown', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
const sessionId = SessionId('background-disposal')
|
|
const header: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false,
|
|
}
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect: () => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events: [],
|
|
}),
|
|
}) as never)
|
|
const controller = createSessionTestController(ctx, defaults)
|
|
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
|
|
const started = Promise.withResolvers<undefined>()
|
|
const release = Promise.withResolvers<undefined>()
|
|
vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => {
|
|
started.resolve(undefined)
|
|
await release.promise
|
|
return {
|
|
agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent,
|
|
}
|
|
})
|
|
const iterator = controller.follow({
|
|
address: { kind: 'session', sessionId },
|
|
}, new AbortController().signal)[Symbol.asyncIterator]()
|
|
|
|
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
|
|
const waiting = iterator.next()
|
|
await started.promise
|
|
let disposed = false
|
|
const disposal = ctx.fiber.dispose().then(() => { disposed = true })
|
|
await Promise.resolve()
|
|
expect(disposed).toBe(false)
|
|
|
|
release.resolve(undefined)
|
|
await disposal
|
|
await expect(waiting).resolves.toMatchObject({ done: true })
|
|
})
|
|
})
|