feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand

Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.

- Extract the `Branded<B>` primitive into a new standalone type-only package
  `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
  dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
  dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
  dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
  generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
  the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
  the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
  SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
  that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
  agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
  at the config boundary and the inner create()/resume casts disappear (only the
  genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
  Map keys and public params/exports (SessionStore, AgentRegistry + factory
  options, the ACP session-id surface + ToolPresenter CallId map, the
  persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
  the Branded type-equiv at dsh-brand, fix stale param types in the session/
  agent/bash READMEs, regenerate the cordis catalog + module graph.

Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
This commit is contained in:
Tianyi Cui
2026-06-21 07:17:25 +08:00
parent 24168aee70
commit d6a2ab30c8
75 changed files with 644 additions and 445 deletions

View File

@@ -60,7 +60,10 @@ import {
type StopReason,
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
@@ -138,7 +141,7 @@ export const Config: Schema<AcpConfig> = Schema.object({
* map keyed by id (RFC 011 multi-session).
*/
interface SessionRecord {
sessionId: string
sessionId: SessionId
agent: Agent
/**
* The owned-agent disposer (from the {@link AgentHandle} the factory returned).
@@ -229,12 +232,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
// reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
// The two stay in lockstep: a record is added to `sessions` and the agent to
// `bySession` together, and removed together.
const sessions = new Map<string, SessionRecord>()
const bySession = new WeakMap<Agent, string>()
const sessions = new Map<SessionId, SessionRecord>()
const bySession = new WeakMap<Agent, SessionId>()
// Session ids whose `session/load` is mid-`resume()` (the slot is reserved
// before the async resume so a pipelined load/new for the SAME id can't create
// two agents). Distinct ids load concurrently; a given id loads once at a time.
const loadingIds = new Set<string>()
const loadingIds = new Set<SessionId>()
// Set once the bridge has torn down (disposal or client disconnect). An async
// `session/load` mid-`resume()` when teardown ran must observe this after its
// await and NOT install a record (which would resurrect a live agent/listeners
@@ -265,7 +268,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
/** Resolve the live record for a sessionId, or throw an ACP error. */
const requireSession = (sessionId: string): SessionRecord => {
const requireSession = (sessionId: SessionId): SessionRecord => {
const rec = sessions.get(sessionId)
if (rec === undefined) {
throw invalidParams(`unknown session: ${sessionId}`)
@@ -446,9 +449,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
assertOpen()
validateWorkspaceParams(params)
validateMcpServers(params)
const sessionId = randomUUID()
const sessionId = SessionId(randomUUID())
const handle = agents.create({
agentId: sessionId,
agentId: AgentId(sessionId),
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
@@ -467,8 +470,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
assertOpen()
if (sessions.has(params.sessionId) || loadingIds.has(params.sessionId)) {
throw invalidParams(`session ${params.sessionId} is already loaded`)
// The wire `params.sessionId` is a raw protocol string; brand it once at
// this entry so the session collections and the resume factory see a SessionId.
const sessionId = SessionId(params.sessionId)
if (sessions.has(sessionId) || loadingIds.has(sessionId)) {
throw invalidParams(`session ${sessionId} is already loaded`)
}
validateWorkspaceParams(params)
validateMcpServers(params)
@@ -477,7 +483,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// resume() is pending, then both install a record and leak a second
// agent. (Distinct ids load concurrently — the set is keyed by id.) The
// slot is released in `finally` so a rejected load never wedges the id.
loadingIds.add(params.sessionId)
loadingIds.add(sessionId)
try {
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a
// metadata-only read (no full-log parse), so this rejects a session we
@@ -491,21 +497,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
// always has a cwd (session/new requires it); reject the rest loudly.
// (An id unknown to `list()` falls through to resume, which rejects with
// the backend's not-found error.)
const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId)
const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
if (meta !== undefined) {
const persistedCwd = meta.cwd
if (persistedCwd === undefined || !isAbsolute(persistedCwd)) {
throw invalidParams(
`session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
`session ${sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
)
}
if (!sameWorkspaceCwd(persistedCwd, params.cwd)) {
throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
}
}
const handle = await agents.resume({
agentId: params.sessionId,
resumeSessionId: params.sessionId,
agentId: AgentId(sessionId),
resumeSessionId: sessionId,
agentOptions: agentOptions(config),
})
// The bridge may have torn down (disposal / client disconnect) while
@@ -523,20 +529,20 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw invalidParams('connection closed during session/load')
}
const agent = handle.agent
bySession.set(agent, params.sessionId)
bySession.set(agent, sessionId)
// Snapshot the terminal capability ONCE for this session (used by both
// the replay below and the post-load live stream) so a later
// `initialize` can't desync the call/result of a tool card.
const terminalEnabled = terminalOutputCap
const record: SessionRecord = {
sessionId: params.sessionId,
sessionId,
agent,
dispose: () => handle.dispose(),
presenter: makePresenter(),
terminalEnabled,
inflight: undefined,
}
sessions.set(params.sessionId, record)
sessions.set(sessionId, record)
// Replay the persisted event log to the client as session/update. Use
// the raw event log (NOT deriveMessages, which drops assistant/chunk
// and trace events): RFC 010's load contract reconstructs the streamed
@@ -556,17 +562,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
cwd: agent.session.header.cwd,
}
for (const event of agent.session.events) {
streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter, replayTerminal)
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
return {}
} finally {
loadingIds.delete(params.sessionId)
loadingIds.delete(sessionId)
}
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const rec = requireSession(params.sessionId)
const rec = requireSession(SessionId(params.sessionId))
if (rec.inflight !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
@@ -595,7 +601,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
},
cancel(params: CancelNotification): Promise<void> {
const rec = sessions.get(params.sessionId)
const rec = sessions.get(SessionId(params.sessionId))
if (rec === undefined) return Promise.resolve()
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
// a RUNNING step, clears the queued + steering FIFOs, and drops a
@@ -773,7 +779,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* no client update.
*/
export function streamSessionEventUpdate(
sessionId: string,
sessionId: SessionId,
event: SessionEvent,
notify: (notification: SessionNotification) => void,
presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
@@ -938,7 +944,7 @@ interface ResolvedResultPresentation {
* stale entry's only cost is one map slot until the session ends.
*/
export class ToolPresenter {
private readonly pending = new Map<string, { name: string; args: unknown; isTerminal: boolean }>()
private readonly pending = new Map<CallId, { name: string; args: unknown; isTerminal: boolean }>()
/**
* @param tools the registry to resolve tool definitions by name.
@@ -954,7 +960,7 @@ export class ToolPresenter {
) {}
/** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */
call(callId: string, name: string, argsJson: string): ResolvedCallPresentation {
call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation {
const args = parseToolArguments(argsJson)
let present: ToolCallPresentation | undefined
try {
@@ -986,7 +992,7 @@ export class ToolPresenter {
}
/** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
result(callId: string, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.

View File

@@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
/**
@@ -61,8 +62,8 @@ describe('acp bridge', () => {
expect(b.sessionId).toBeTruthy()
expect(a.sessionId).not.toBe(b.sessionId)
// Both agents are live and independently registered.
expect(harness.ctx.agents.get(a.sessionId)).toBeDefined()
expect(harness.ctx.agents.get(b.sessionId)).toBeDefined()
expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined()
expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined()
})
it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => {
@@ -77,7 +78,7 @@ describe('acp bridge', () => {
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
expect(res.sessionId).toBeTruthy()
// The session header records that cwd, so its bash tools run there.
expect(harness.ctx.agents.get(res.sessionId)!.session.header.cwd).toBe('/tmp')
expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp')
})
it('rejects non-empty additionalDirectories', async () => {
@@ -117,7 +118,7 @@ describe('acp bridge', () => {
],
})
expect(result.stopReason).toBe('end_turn')
const user = harness.ctx.agents.get(sessionId)!.session.events.find(event => event.type === 'user/message')
const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message')
expect(JSON.stringify(user)).toContain('resource_link')
})

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse } from './harness.ts'
describe('acp bridge — disposal & HMR safety', () => {
@@ -16,7 +17,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(sessionId)!
const agent = harness.ctx.agents.get(AgentId(sessionId))!
// Start a prompt that hangs in the model stream.
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
@@ -61,10 +62,10 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(harness.ctx.agents.get(sessionId)).toBeDefined()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined()
await harness.acpFiber.dispose() // tear down ONLY the bridge
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
await harness.dispose()
})
@@ -91,7 +92,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(sessionId)!
const agent = harness.ctx.agents.get(AgentId(sessionId))!
// Start a prompt that hangs in the model stream. The prompt RPC will never
// return (its transport is severed), so do not await it.
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
@@ -114,8 +115,8 @@ describe('acp bridge — disposal & HMR safety', () => {
// and its session removed from the store, not merely idled (the old
// behavior). The services live on the root ctx, so they survive this.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
expect(harness.ctx.sessions.get(sessionId)).toBeUndefined()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
await harness.dispose()
})
@@ -127,7 +128,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(sessionId)!
const agent = harness.ctx.agents.get(AgentId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
@@ -144,7 +145,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const session = harness.ctx.agents.get(sessionId)!.session
const session = harness.ctx.agents.get(AgentId(sessionId))!.session
await harness.ctx.fiber.dispose()
const before = harness.updates.length
@@ -168,12 +169,12 @@ describe('acp bridge — disposal & HMR safety', () => {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
const liveEvents = harness.ctx.agents.get(sessionId)!.session.events.length
const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length
expect(liveEvents).toBeGreaterThan(0)
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
// Re-load the session from disk: every live event (incl. the closing
// turn/end) was flushed before the session was detached.
@@ -200,7 +201,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(sessionId)!
const agent = harness.ctx.agents.get(AgentId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
@@ -210,7 +211,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
// teardown (the composite effect runs its disposer chain as a unit).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
// The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
// self-report) — NOT a crash-recovery `interrupted` substitute.
@@ -229,22 +230,22 @@ describe('acp bridge — disposal & HMR safety', () => {
// queryable, with its session still in the store.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = harness.ctx.agents.create({
agentId: 'sib-a', sessionId: 'sib-a', agentOptions: { model: 'mock' },
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
})
const handleB = harness.ctx.agents.create({
agentId: 'sib-b', sessionId: 'sib-b', agentOptions: { model: 'mock' },
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
})
expect(harness.ctx.agents.get('sib-a')).toBe(handleA.agent)
expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent)
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
await handleA.dispose()
// A is gone — unregistered AND its session removed from the store.
expect(harness.ctx.agents.get('sib-a')).toBeUndefined()
expect(harness.ctx.sessions.get('sib-a')).toBeUndefined()
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed')
// B is wholly unaffected.
expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent)
expect(harness.ctx.sessions.get('sib-b')).toBeDefined()
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
expect(handleB.agent.status).not.toBe('disposed')
await harness.dispose()
})
@@ -261,16 +262,16 @@ describe('acp bridge — disposal & HMR safety', () => {
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = harness.ctx.agents.create({
agentId: 'guard-a', sessionId: 'guard-a', agentOptions: { model: 'mock' },
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'go' }])
await handle.agent.whenIdle()
expect(harness.ctx.sessions.get('guard-a')).toBeDefined()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
// Dispose: the throwing listener must NOT break the chain before detach.
await handle.dispose()
expect(harness.ctx.agents.get('guard-a')).toBeUndefined()
expect(harness.ctx.sessions.get('guard-a')).toBeUndefined() // detach still ran
expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
await harness.dispose()
})
@@ -282,7 +283,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// observe the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = harness.ctx.agents.create({
agentId: 'conc-a', sessionId: 'conc-a', agentOptions: { model: 'mock' },
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
})
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
// disposed — its exit runs a final session/flush we can gate to hold the
@@ -312,8 +313,8 @@ describe('acp bridge — disposal & HMR safety', () => {
// Release the flush; both resolve together and the session is gone.
releaseFlush()
await Promise.all([first, second])
expect(harness.ctx.agents.get('conc-a')).toBeUndefined()
expect(harness.ctx.sessions.get('conc-a')).toBeUndefined()
expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
await harness.dispose()
})
})

View File

@@ -3,6 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
describe('acp bridge — demux & config edges', () => {
@@ -25,7 +27,7 @@ describe('acp bridge — demux & config edges', () => {
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const before = harness.updates.length
const { agent: foreign } = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } })
const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
foreign.send([{ type: 'text', text: 'hi' }])
await foreign.whenIdle()
await new Promise(r => setTimeout(r, 10))

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
/** Concatenate the text of all agent_message_chunk updates. */
@@ -155,7 +156,7 @@ describe('acp bridge — session/load replay', () => {
release() // resume() finishes AFTER teardown
expect(await loadResult).toBe('rejected')
// No live agent was installed for the closed connection.
expect(loader.ctx.agents.get(sessionId)).toBeUndefined()
expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
})
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
@@ -176,11 +177,11 @@ describe('acp bridge — session/load replay', () => {
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/cwd mismatch/)
expect(loader.ctx.agents.get('elsewhere')).toBeUndefined()
expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined()
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
expect(res).toBeDefined()
expect(loader.ctx.agents.get('elsewhere')!.session.header.cwd).toBe(otherCwd)
expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
})
it('rejects load for a non-absolute cwd (still required to be absolute)', async () => {
@@ -215,7 +216,7 @@ describe('acp bridge — session/load replay', () => {
// Rejected BEFORE resume (metadata-only check) — no agent was registered, so
// the id is not wedged: a later attempt hits the same clean rejection, not a
// duplicate-registration error.
expect(loader.ctx.agents.get('legacy')).toBeUndefined()
expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined()
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/no absolute persisted cwd/)
})

View File

@@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
/** Text of the agent_message_chunk updates scoped to one session id. */
@@ -101,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const agentA = harness.ctx.agents.get(a)!
const agentB = harness.ctx.agents.get(b)!
const agentA = harness.ctx.agents.get(AgentId(a))!
const agentB = harness.ctx.agents.get(AgentId(b))!
// Wait deterministically for BOTH agents to enter `running` (not a fixed
// sleep — agent startup latency is unbounded on a loaded worker).

View File

@@ -17,7 +17,7 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionNotification } from '@agentclientprotocol/sdk'
import { streamSessionEventUpdate } from '../src/index.ts'
@@ -85,7 +85,7 @@ function actionsToEvents(actions: Action[]): SessionEvent[] {
function runStream(events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update))
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update))
return out
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionNotification } from '@agentclientprotocol/sdk'
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts'
@@ -8,14 +8,14 @@ import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/in
/** Collect the updates a single event produces (no presenter → generic fallback). */
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
streamSessionEventUpdate('s1', event, n => out.push(n.update))
streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update))
return out
}
/** Collect the updates emitted by the live prompt stream (user echo suppressed). */
function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
streamSessionEventUpdate('s1', event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
return out
}
@@ -138,7 +138,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter)
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
@@ -324,7 +324,7 @@ describe('terminal-card mapping (capability-gated)', () => {
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
const presenter = new ToolPresenter(registryOf(tool))
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd })
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, { enabled, cwd })
return out
}

View File

@@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import {
errorResponse,
@@ -283,7 +284,7 @@ describe('acp bridge — turn outcomes', () => {
// OWN turn with the real model answer.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(sessionId)!
const agent = harness.ctx.agents.get(AgentId(sessionId))!
// On the queued prompt, synchronously inject a one-shot context turn (idle
// inject writes turn/start{injection} → context/message → turn/end). Fire
// once so it lands between install and the prompt turn.
@@ -339,7 +340,7 @@ describe('acp bridge — turn outcomes', () => {
await harness.client.cancel({ sessionId })
const res = await promptDone
expect(res.stopReason).toBe('cancelled')
const agent = harness.ctx.agents.get(sessionId)!
const agent = harness.ctx.agents.get(AgentId(sessionId))!
await agent.whenIdle()
// At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so
// no second turn was batched or leaked. (A best-effort abort that left queued