DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).
Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
addressed by the plugin row's Loader entry id and read from the
module's exported Config. The preferences are merged into Config and
marked volatile — which requires DSH's own schemastery, because only
that build wraps a volatile field in the cosmokit reference the
loader's volatile commit walks. With the public package a settings
write reports success and revision 1 while the effective value never
changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
mode is render-time, not load-time. Five call sites that relied on the
old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
boundary injection now writes plugin:dsh-better-sidebar, the same kind
DSH's own v3->v4 migration produces, so historical threads survive.
Tool results moved to role 'tool' with top-level content/isError; all
four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
and the on-demand subagent-catalog observation API. The Tasks page
reads projectionsBySession and a new jobs.list route.
Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
spreadsheet worker, zoom, per-directory refresh); editor.canOpen
refuses those 26 extensions. Markdown, HTML (with its sandbox
switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
host, fixing a regression from the 0.1.6 round: the fallback pointed
at a 'browser' type the plugin no longer registers, so http links in
its own markdown did nothing.
Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
refreshSubagents do not exist on the current baseline, so the
topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
primitives call at module-evaluation time; and the e2e token exchange
needs Playwright's request stack, not Node's fetch.
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
/**
|
|
* Host route tests for the background-job API ('jobs.output' / 'jobs.kill').
|
|
* 'jobs.output' REPLAYS the output the model has read so far from the owner
|
|
* session's event log (tool/call + tool/result pairs of job_output calls) —
|
|
* the model's cursor is never touched, so nothing is stolen and unread jobs
|
|
* report `read: false`. 'jobs.kill' uses the registry's stock kill, fenced
|
|
* by the owning session, with a 503 when the registry is absent.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
|
import { buildJobsApi } from '../src/jobs-routes.ts'
|
|
import { SidebarError } from '../src/wire.ts'
|
|
import type { Context, SidebarSessionEvent } from '../src/context-types.ts'
|
|
|
|
/** A context whose `get` serves only the jobs face, with a session store. */
|
|
function ctxWith(sessions: unknown, jobs: unknown): Context {
|
|
return {
|
|
sessions,
|
|
get: (key: string) => (key === 'jobs' ? jobs : undefined),
|
|
} as unknown as Context
|
|
}
|
|
|
|
/** A context that additionally captures the session/event listener (live mirror). */
|
|
function ctxWithFeed(sessions: unknown): {
|
|
ctx: Context
|
|
emit: (session: unknown, event: SidebarSessionEvent) => void
|
|
} {
|
|
let listener: ((session: unknown, event: SidebarSessionEvent) => void) | undefined
|
|
const base = ctxWith(sessions, undefined) as unknown as {
|
|
on: (event: string, fn: (session: unknown, event: SidebarSessionEvent) => void) => () => void
|
|
effect: (fn: () => void | (() => void)) => void
|
|
}
|
|
base.on = (_event: string, fn) => {
|
|
listener = fn
|
|
return () => { if (listener === fn) listener = undefined }
|
|
}
|
|
// The vendored cordis runs the registration effect immediately.
|
|
base.effect = (fn) => { fn() }
|
|
return {
|
|
ctx: base as unknown as Context,
|
|
emit: (session, event) => { listener?.(session, event) },
|
|
}
|
|
}
|
|
|
|
/** One job_output tool/call event. */
|
|
function jobOutputCall(seq: number, callId: string, jobId: string): SidebarSessionEvent {
|
|
return { type: 'tool/call', seq, time: seq, data: { name: 'job_output', callId, arguments: JSON.stringify({ job_id: jobId }) } }
|
|
}
|
|
|
|
/** One tool/result event carrying the finalized text the model received —
|
|
* the 0.1.7 first-class tool-role shape (content + isError on the message). */
|
|
function jobOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
|
|
return {
|
|
type: 'tool/result',
|
|
seq,
|
|
time: seq,
|
|
data: {
|
|
message: {
|
|
role: 'tool',
|
|
source: { kind: 'tool', callId },
|
|
toolCallId: callId,
|
|
isError: over.isError === true,
|
|
content: [{ type: 'text', text }],
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/** The retired 0.1.6 shape: role-'user' message wrapping the result in one
|
|
* `tool-result` block. Historical logs still carry it. */
|
|
function legacyJobOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
|
|
return {
|
|
type: 'tool/result',
|
|
seq,
|
|
time: seq,
|
|
data: {
|
|
message: {
|
|
role: 'user',
|
|
source: { kind: 'tool', callId },
|
|
content: [{
|
|
type: 'tool-result',
|
|
toolCallId: callId,
|
|
isError: over.isError === true,
|
|
content: [{ type: 'text', text }],
|
|
}],
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/** The owner session with the given event log. */
|
|
function session(events: SidebarSessionEvent[]): unknown {
|
|
return { header: { cwd: '/p' }, snapshotEvents: () => events }
|
|
}
|
|
|
|
describe('jobs.output route (event replay)', () => {
|
|
it('concatenates the job_output results the model read for the job, oldest first', () => {
|
|
const events = [
|
|
jobOutputCall(0, 'c1', 'bash-1'),
|
|
jobOutputResult(1, 'c1', 'line1\nline2\n[status: running]'),
|
|
jobOutputCall(2, 'c2', 'bash-1'),
|
|
jobOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'),
|
|
// Another job's reads are ignored.
|
|
jobOutputCall(4, 'c3', 'bash-2'),
|
|
jobOutputResult(5, 'c3', 'other job output'),
|
|
]
|
|
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
|
text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
})
|
|
|
|
it('skips the controller\'s "(no new output)" deltas and error results', () => {
|
|
const events = [
|
|
jobOutputCall(0, 'c1', 'bash-1'),
|
|
jobOutputResult(1, 'c1', 'real output\n[status: running]'),
|
|
jobOutputCall(2, 'c2', 'bash-1'),
|
|
jobOutputResult(3, 'c2', '(no new output)\n[status: running]'),
|
|
jobOutputCall(4, 'c3', 'bash-1'),
|
|
jobOutputResult(5, 'c3', 'boom', { isError: true }),
|
|
]
|
|
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
|
text: 'real output\n[status: running]',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
})
|
|
|
|
it('reads the tool/result message dsh-llm actually produces (producer round-trip)', () => {
|
|
const message = createToolResultMessage({
|
|
callId: ToolCallId('c1'),
|
|
content: [{ type: 'text', text: 'produced output\n[status: running]' }],
|
|
isError: false,
|
|
})
|
|
const events: SidebarSessionEvent[] = [
|
|
jobOutputCall(0, 'c1', 'bash-1'),
|
|
{ type: 'tool/result', seq: 1, time: 1, data: { message } },
|
|
]
|
|
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
|
text: 'produced output\n[status: running]',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
})
|
|
|
|
it('reads a LEGACY 0.1.6-era tool-result wrapper (historical logs keep that shape)', () => {
|
|
// The first legacy read is an error (its wrapper flag must still be
|
|
// honored), the second supplies the text.
|
|
const events = [
|
|
jobOutputCall(0, 'c1', 'bash-1'),
|
|
legacyJobOutputResult(1, 'c1', 'legacy boom', { isError: true }),
|
|
jobOutputCall(2, 'c2', 'bash-1'),
|
|
legacyJobOutputResult(3, 'c2', 'legacy line\n[status: running]'),
|
|
]
|
|
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
|
text: 'legacy line\n[status: running]',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
})
|
|
|
|
it('reports read:false until the model reads the job (no registry call at all)', () => {
|
|
const events = [jobOutputCall(0, 'c1', 'bash-2')]
|
|
const jobs = { kill: vi.fn() }
|
|
const api = buildJobsApi(ctxWith({ get: () => session(events) }, jobs), 100)
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
|
// The replay never touches the registry — the model's cursor is safe by construction.
|
|
expect(jobs.kill).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('mirrors live job_output events the store log misses (restart divergence)', () => {
|
|
// The store session is frozen at its rehydration boundary (no events);
|
|
// the read exists only on the live session/event feed.
|
|
const { ctx, emit } = ctxWithFeed({ get: () => session([]) })
|
|
const api = buildJobsApi(ctx, 512 * 1024)
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
|
|
|
// The live feed delivers the job_output call and its result.
|
|
emit({ id: 's1' }, jobOutputCall(100, 'c-live', 'bash-1'))
|
|
emit({ id: 's1' }, jobOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]'))
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
|
text: 'live line\n[status: completed, exit code: 0]',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
|
|
// Unrelated results are never cached (no job_output call paired them),
|
|
// and another session's feed does not leak into this one.
|
|
emit({ id: 's1' }, jobOutputResult(102, 'c-other', 'unpaired line'))
|
|
emit({ id: 's2' }, jobOutputCall(103, 'c-s2', 'bash-1'))
|
|
emit({ id: 's2' }, jobOutputResult(104, 'c-s2', 'other session line'))
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
|
text: 'live line\n[status: completed, exit code: 0]',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
expect(api.output({ sessionId: 's2', id: 'bash-1' })).toEqual({
|
|
text: 'other session line',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
})
|
|
|
|
it('merges store-log traces with live mirror traces without double-counting', () => {
|
|
// A seed read (seq 5) in the store log plus a live read (seq 106) that
|
|
// ALSO reached the store log would double-count — the seq dedupe keeps
|
|
// exactly one copy of each.
|
|
const events = [jobOutputCall(5, 'c-seed', 'bash-1'), jobOutputResult(6, 'c-seed', 'seed line')]
|
|
const { ctx, emit } = ctxWithFeed({ get: () => session(events) })
|
|
const api = buildJobsApi(ctx, 512 * 1024)
|
|
emit({ id: 's1' }, jobOutputCall(106, 'c-live', 'bash-1'))
|
|
emit({ id: 's1' }, jobOutputResult(107, 'c-live', 'live line'))
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
|
text: 'seed line\nlive line',
|
|
truncated: false,
|
|
read: true,
|
|
})
|
|
})
|
|
|
|
it('caps oversized replays with the truncated flag', () => {
|
|
const events = [jobOutputCall(0, 'c1', 'bash-1'), jobOutputResult(1, 'c1', 'x'.repeat(10_000))]
|
|
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 100)
|
|
const value = api.output({ sessionId: 's1', id: 'bash-1' })
|
|
expect(value.text).toBe('x'.repeat(100))
|
|
expect(value.truncated).toBe(true)
|
|
expect(value.read).toBe(true)
|
|
})
|
|
|
|
it('rejects a missing sessionId or id as bad-request', () => {
|
|
const api = buildJobsApi(ctxWith({ get: () => undefined }, undefined), 100)
|
|
expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
|
|
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
|
|
})
|
|
})
|
|
|
|
describe('jobs.kill route', () => {
|
|
it('kills with the forwarded reason and the owning session id', () => {
|
|
const jobs = { kill: vi.fn(() => 'requested' as const) }
|
|
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs), 100)
|
|
expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' }))
|
|
.toEqual({ ok: true, outcome: 'requested' })
|
|
// 0.1.7's registry fences by SessionId; the 0.1.6 Agent object is gone.
|
|
expect(jobs.kill).toHaveBeenCalledWith('bash-1', 's1', 'user pressed stop')
|
|
})
|
|
|
|
it('defaults the reason when none is supplied', () => {
|
|
const jobs = { kill: vi.fn(() => 'already-finished' as const) }
|
|
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs), 100)
|
|
expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' })
|
|
expect(jobs.kill).toHaveBeenCalledWith('bash-1', 's1', 'user requested via sidebar')
|
|
})
|
|
|
|
it('maps registry refusals to a 404 job-error', () => {
|
|
const jobs = { kill: vi.fn(() => { throw new Error('unknown job bash-9') }) }
|
|
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs), 100)
|
|
expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError(
|
|
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 404 }),
|
|
)
|
|
})
|
|
|
|
it('degrades to a 503 when the jobs registry is absent (output keeps working)', () => {
|
|
const api = buildJobsApi(ctxWith({ get: () => session([]) }, undefined), 100)
|
|
expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError(
|
|
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 503 }),
|
|
)
|
|
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
|
})
|
|
})
|