# Conflicts: # .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml # .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # 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/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # packages/acp/acp/README.i18n.yaml # packages/acp/acp/src/index.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-goal/README.i18n.yaml # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/tool-goal/README.i18n.yaml # packages/goal/tool-goal/README.md # packages/goal/tool-goal/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/src/index.ts # packages/session-persistence/session-persistence/tests/coordinator-contract.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent-inprocess/README.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/support/acp-snapshot/src/harness.ts # packages/ui/tui/tests/tui.spec.ts
199 lines
7.6 KiB
TypeScript
199 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
|
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
|
|
async function mount(): Promise<Context> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
return ctx
|
|
}
|
|
|
|
async function mintScope(ctx: Context, name: string): Promise<Scope> {
|
|
let scope!: Scope
|
|
// The scoped context resolves services through the MINTING plugin's
|
|
// dependency chain — the minter must inject what scope holders will reach.
|
|
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
|
|
{ inject: ['sessions'] }))
|
|
return scope
|
|
}
|
|
|
|
/** The key a test scope was minted with. */
|
|
function keyOf(scope: Scope): ScopeKey {
|
|
|
|
return scopeOf(scope.ctx)!
|
|
}
|
|
|
|
describe('session dispatch carriers', () => {
|
|
it('a session entered through a scoped context dispatches its events in that scope', async () => {
|
|
const ctx = await mount()
|
|
const scope = await mintScope(ctx, 'owner')
|
|
const otherScope = await mintScope(ctx, 'other')
|
|
|
|
const heard: string[] = []
|
|
ctx.on('session/event', (_session, event) => void heard.push(`global:${event.type}`))
|
|
scope.ctx.on('session/event', (_session, event) => void heard.push(`owner:${event.type}`))
|
|
otherScope.ctx.on('session/event', (_session, event) => void heard.push(`other:${event.type}`))
|
|
scope.ctx.on('session/created', session => void heard.push(`owner-created:${session.id}`))
|
|
otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
|
|
|
|
const session = scope.ctx.sessions.create()
|
|
session.append('turn/start', { turn: 1 })
|
|
|
|
expect(heard).toEqual([
|
|
`owner-created:${session.id}`,
|
|
'global:turn/start',
|
|
'owner:turn/start',
|
|
])
|
|
})
|
|
|
|
it('a bare session dispatches subject-less: scoped listeners never hear it', async () => {
|
|
const ctx = await mount()
|
|
const scope = await mintScope(ctx, 'owner')
|
|
const heard: string[] = []
|
|
ctx.on('session/event', (_s, event) => void heard.push(`global:${event.type}`))
|
|
scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
|
|
|
|
const bare = ctx.sessions.create()
|
|
bare.append('turn/start', { turn: 1 })
|
|
expect(heard).toEqual(['global:turn/start'])
|
|
})
|
|
|
|
it('reuses the captured owner carrier for the paired disposal notification', async () => {
|
|
const ctx = await mount()
|
|
const owner = await mintScope(ctx, 'owner')
|
|
const other = await mintScope(ctx, 'other')
|
|
const heard: string[] = []
|
|
ctx.on('session/disposed', (session) => { heard.push(`global:${session.id}`) })
|
|
owner.ctx.on('session/disposed', (session) => { heard.push(`owner:${session.id}`) })
|
|
other.ctx.on('session/disposed', (session) => { heard.push(`other:${session.id}`) })
|
|
|
|
const session = owner.ctx.sessions.prepare()
|
|
const detach = owner.ctx.sessions.enter(session)
|
|
owner.ctx.sessions.announce(session)
|
|
detach()
|
|
|
|
expect(heard).toEqual([`global:${session.id}`, `owner:${session.id}`])
|
|
})
|
|
})
|
|
|
|
describe('sessions.flush()', () => {
|
|
it('allows an ordinary flush with no listeners', async () => {
|
|
const ctx = await mount()
|
|
const session = ctx.sessions.create()
|
|
|
|
await expect(ctx.sessions.flush(session)).resolves.toBe(false)
|
|
})
|
|
|
|
it('reports a participating listener after it succeeds', async () => {
|
|
const ctx = await mount()
|
|
const session = ctx.sessions.create()
|
|
const flushed: Session[] = []
|
|
ctx.on('session/flush', current => void flushed.push(current))
|
|
|
|
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
|
|
|
expect(flushed).toEqual([session])
|
|
})
|
|
|
|
it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
|
|
const ctx = await mount()
|
|
const scope = await mintScope(ctx, 'owner')
|
|
const flushed: string[] = []
|
|
ctx.on('session/flush', async (session: Session) => {
|
|
await Promise.resolve()
|
|
flushed.push(`global:${session.id}`)
|
|
})
|
|
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
|
|
|
const owned = scope.ctx.sessions.create()
|
|
const bare = ctx.sessions.create()
|
|
await ctx.sessions.flush(owned)
|
|
await ctx.sessions.flush(bare)
|
|
|
|
// Parallel dispatch: listener completion order is unspecified (the global
|
|
// listener awaits a microtask) — assert set membership per flush instead.
|
|
expect(flushed.slice(0, 2).sort()).toEqual([`global:${owned.id}`, `owner:${owned.id}`])
|
|
expect(flushed.slice(2)).toEqual([`global:${bare.id}`])
|
|
})
|
|
|
|
it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => {
|
|
const ctx = await mount()
|
|
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
|
|
const session = ctx.sessions.create()
|
|
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
|
})
|
|
|
|
it('does not let a synchronous flush failure starve later listeners', async () => {
|
|
const ctx = await mount()
|
|
const flushed: Session[] = []
|
|
ctx.on('session/flush', () => { throw new Error('disk full') })
|
|
ctx.on('session/flush', (session) => { flushed.push(session) })
|
|
const session = ctx.sessions.create()
|
|
|
|
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
|
expect(flushed).toEqual([session])
|
|
})
|
|
|
|
it('waits for slower flush listeners before reporting another listener failure', async () => {
|
|
const ctx = await mount()
|
|
const gate = Promise.withResolvers<undefined>()
|
|
let slowStarted = false
|
|
let settled = false
|
|
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
|
|
ctx.on('session/flush', () => {
|
|
slowStarted = true
|
|
return gate.promise
|
|
})
|
|
const session = ctx.sessions.create()
|
|
|
|
const flushing = ctx.sessions.flush(session)
|
|
void flushing.finally(() => { settled = true }).catch(() => undefined)
|
|
await Promise.resolve()
|
|
expect(slowStarted).toBe(true)
|
|
expect(settled).toBe(false)
|
|
|
|
gate.resolve(undefined)
|
|
await expect(flushing).rejects.toThrow('disk full')
|
|
expect(settled).toBe(true)
|
|
})
|
|
|
|
it('rejects a never-entered session instead of inventing a carrier', async () => {
|
|
const ctx = await mount()
|
|
const scope = await mintScope(ctx, 'owner')
|
|
const flushed: string[] = []
|
|
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
|
|
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
|
|
|
const prepared = ctx.sessions.prepare()
|
|
await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
|
|
expect(flushed).toEqual([])
|
|
})
|
|
|
|
it('clears a detached carrier and rejects stale flushes', async () => {
|
|
const ctx = await mount()
|
|
const scope = await mintScope(ctx, 'owner')
|
|
const flushed: string[] = []
|
|
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
|
|
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
|
|
|
const session = scope.ctx.sessions.prepare()
|
|
const detach = scope.ctx.sessions.enter(session)
|
|
await ctx.sessions.flush(session)
|
|
expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`])
|
|
|
|
detach()
|
|
await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/)
|
|
expect(flushed).toHaveLength(2)
|
|
})
|
|
|
|
it('keyOf sanity: distinct scopes carry distinct keys', async () => {
|
|
const ctx = await mount()
|
|
const a = await mintScope(ctx, 'a')
|
|
const b = await mintScope(ctx, 'b')
|
|
expect(keyOf(a)).not.toBe(keyOf(b))
|
|
})
|
|
})
|