mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
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
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
|
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
|
|
|
|
/** Minimal concrete executor: records calls, lets tests drive completions. */
|
|
class StubExecutor extends BashExecutor {
|
|
tasks = new Map<BashTaskId, BashTask>()
|
|
private owners = new Map<BashTaskId, OwnerToken | undefined>()
|
|
|
|
resolve(request: BashExecRequest): BashExecSpec {
|
|
return {
|
|
command: request.command,
|
|
workdir: request.workdir ?? '/stub',
|
|
timeoutMs: request.timeoutMs ?? 1000,
|
|
...request.signal ? { signal: request.signal } : {},
|
|
owner: request.owner,
|
|
}
|
|
}
|
|
|
|
async run(_spec: BashExecSpec): Promise<BashRunResult> {
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
aborted: false,
|
|
timeoutMs: 1000,
|
|
stdout: { text: 'ok', truncated: false },
|
|
stderr: { text: '', truncated: false },
|
|
}
|
|
}
|
|
|
|
start(spec: BashExecSpec): BashTask {
|
|
const task: BashTask = {
|
|
id: BashTaskId(`stub-${this.tasks.size + 1}`),
|
|
command: spec.command,
|
|
status: 'running',
|
|
exitCode: null,
|
|
signal: null,
|
|
done: Promise.resolve(),
|
|
}
|
|
this.tasks.set(task.id, task)
|
|
this.owners.set(task.id, spec.owner)
|
|
return task
|
|
}
|
|
|
|
get(id: BashTaskId): BashTask | undefined {
|
|
return this.tasks.get(id)
|
|
}
|
|
|
|
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
|
return this.owners.get(id)
|
|
}
|
|
|
|
list(): BashTask[] {
|
|
return [...this.tasks.values()]
|
|
}
|
|
|
|
readOutput(id: BashTaskId): BashTaskRead {
|
|
const task = this.tasks.get(id)
|
|
if (!task) throw new Error(`unknown bash task "${id}"`)
|
|
return { task, delta: '', lossy: false }
|
|
}
|
|
|
|
kill(id: BashTaskId): boolean {
|
|
const task = this.tasks.get(id)
|
|
if (!task) throw new Error(`unknown bash task "${id}"`)
|
|
if (task.status !== 'running') return false
|
|
task.status = 'killed'
|
|
return true
|
|
}
|
|
|
|
/** Expose the protected notifier for tests. */
|
|
fire(task: BashTask): void {
|
|
this.notifyTaskDone(task)
|
|
}
|
|
}
|
|
|
|
async function setup() {
|
|
const ctx = new Context()
|
|
await ctx.plugin(StubExecutor)
|
|
// ctx.bash resolves to the registered implementation.
|
|
const bash = ctx.bash as StubExecutor
|
|
return { ctx, bash }
|
|
}
|
|
|
|
describe('BashExecutor service seam', () => {
|
|
it('registers as ctx.bash and serves the abstract API', async () => {
|
|
const { bash } = await setup()
|
|
const task = bash.start(bash.resolve({ command: 'sleep 1' }))
|
|
expect(bash.get(task.id)).toBe(task)
|
|
expect(bash.list()).toEqual([task])
|
|
expect(bash.kill(task.id)).toBe(true)
|
|
expect(bash.kill(task.id)).toBe(false)
|
|
const result = await bash.run(bash.resolve({ command: 'true' }))
|
|
expect(result.exitCode).toBe(0)
|
|
})
|
|
|
|
it('onTaskDone delivers completions to registered listeners', async () => {
|
|
const { bash } = await setup()
|
|
const seen: string[] = []
|
|
bash.onTaskDone(task => void seen.push(task.id))
|
|
const task = bash.start(bash.resolve({ command: 'x' }))
|
|
bash.fire(task)
|
|
expect(seen).toEqual([task.id])
|
|
})
|
|
|
|
it('onTaskDone disposer unsubscribes the listener', async () => {
|
|
const { bash } = await setup()
|
|
const listener = vi.fn()
|
|
const dispose = bash.onTaskDone(listener)
|
|
dispose()
|
|
bash.fire(bash.start(bash.resolve({ command: 'x' })))
|
|
expect(listener).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('listeners registered from a fiber are removed on dispose (HMR safety)', async () => {
|
|
const { ctx, bash } = await setup()
|
|
const listener = vi.fn()
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
inner.bash.onTaskDone(listener)
|
|
}, { inject: ['bash'] }))
|
|
bash.fire(bash.start(bash.resolve({ command: 'one' })))
|
|
expect(listener).toHaveBeenCalledTimes(1)
|
|
|
|
await fiber.dispose()
|
|
bash.fire(bash.start(bash.resolve({ command: 'two' })))
|
|
expect(listener).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('silences listeners once the service fiber is disposed', async () => {
|
|
const ctx = new Context()
|
|
const fiber = await ctx.plugin(Object.assign(async (inner: Context) => {
|
|
await inner.plugin(StubExecutor)
|
|
}, {}))
|
|
const bash = ctx.bash as StubExecutor
|
|
const listener = vi.fn()
|
|
bash.onTaskDone(listener)
|
|
const task = bash.start(bash.resolve({ command: 'x' }))
|
|
|
|
await fiber.dispose()
|
|
bash.fire(task)
|
|
expect(listener).not.toHaveBeenCalled()
|
|
})
|
|
})
|