refactor(tasks): 后台任务改读宿主客户端 jobs 服务,删除自建三条路由
- 新增 src/client/jobs-client.ts:结构探测 ctx.jobs(watchRows / observe / state / kill),引用计数的按会话 roster 订阅、只观测展开的那一行、 跨树去重(无主任务归到承载它的被观测会话,树外 owner 丢弃)。 - 抽屉与输出浮窗改读该服务:行来自推送 roster(状态 / 时长 / owner / 进度), 输出面换 TerminalBlock 显示宿主为观察者流的保留输出(gapBefore → 截断提示、 error → 失败提示),kill 走 jobs.kill(owner, jobId),两击确认不变。 - 自动激活(use-host-feeds)改骑同一条推送 roster;detectNewJob 增加 「观测起点时钟」参数,避免「页面打开时已在跑的任务」被当成新工作。 - 删除 src/jobs-routes.ts(+ tests/jobs-routes.spec.ts)、index.ts 的三条路由、 api.jobsList/jobOutput/jobKill 与 JobOutputResult、context-types 的 SidebarJobsService(换成 SidebarClientJobsService/SidebarJobsSnapshot/ SidebarObservedJob 镜像,SidebarJobView 补 owner/progress)、 页面里的 jobs.list 轮询与 collectTreeJobs。 - 三个 spec 改用假 ctx.jobs 服务(jobs-view 14 例 / 自动激活 12 例 / 页面 20 例):观测只在展开行、切换与关闭释放观测、watch 隐藏时不建立、 卸载时全部释放、无服务时不渲染任务面。 验证:typecheck / lint 通过;pnpm test 1240 passed / 9 skipped;pnpm build + pack 通过。 (cherry picked from commit 7d9b82e73190218f05910433ed520cb90e4d4ab2)
This commit is contained in:
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* 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 })
|
||||
})
|
||||
})
|
||||
@@ -17,8 +17,8 @@
|
||||
* (`ctx.sidebarRight.mounted`): the session-list snapshot never carried a
|
||||
* current-session field, so the park gate used to read a phantom one and was
|
||||
* permanently false. Background jobs are likewise no longer mirrored into that
|
||||
* snapshot — the trigger polls the plugin's `jobs.list` route, so a job is
|
||||
* delivered by mutating the stubbed registry and advancing the poll.
|
||||
* snapshot — they arrive as whole-set rosters PUSHED by the host's client jobs
|
||||
* service, so a job is delivered by publishing a frame.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -37,7 +37,13 @@ import {
|
||||
type SidebarSurface,
|
||||
type TabComponentProps,
|
||||
} from '../src/client/service.ts'
|
||||
import type { Context, SidebarJobView, SidebarSessionList } from '../src/context-types.ts'
|
||||
import type {
|
||||
Context,
|
||||
SidebarClientJobsService,
|
||||
SidebarJobsSnapshot,
|
||||
SidebarJobView,
|
||||
SidebarSessionList,
|
||||
} from '../src/context-types.ts'
|
||||
|
||||
class FakeWebSocket {
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
@@ -162,15 +168,58 @@ interface MountedSidebar {
|
||||
surface: NativeSurfaceSpy
|
||||
column: NativeColumnSpy
|
||||
sessionId: string
|
||||
/** The stubbed `jobs.list` registry, keyed by OWNER session (mutable). */
|
||||
jobs: Record<string, SidebarJobView[]>
|
||||
/** The client jobs feed the auto-open trigger watches (push rosters). */
|
||||
jobs: JobsFeed
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The host client jobs service double: `watchRows` opens a reference-counted
|
||||
* roster stream and `publish` pushes a whole-set frame through the same
|
||||
* subscribe/getSnapshot pair the trigger reads.
|
||||
*/
|
||||
interface JobsFeed {
|
||||
service: SidebarClientJobsService
|
||||
watched: string[]
|
||||
released: string[]
|
||||
publish(sessionId: string, jobs: readonly SidebarJobView[]): void
|
||||
}
|
||||
|
||||
function makeJobsFeed(): JobsFeed {
|
||||
let snapshot: SidebarJobsSnapshot = { rows: {}, observed: {} }
|
||||
const listeners = new Set<() => void>()
|
||||
const feed: JobsFeed = {
|
||||
watched: [],
|
||||
released: [],
|
||||
service: {
|
||||
state: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
watchRows(sessionId: string) {
|
||||
feed.watched.push(sessionId)
|
||||
return () => { feed.released.push(sessionId) }
|
||||
},
|
||||
observe: () => () => {},
|
||||
kill: async () => {},
|
||||
},
|
||||
publish(sessionId, jobs) {
|
||||
snapshot = { rows: { ...snapshot.rows, ...(jobs.length === 0 ? {} : { [sessionId]: [...jobs] }) }, observed: {} }
|
||||
if (jobs.length === 0) {
|
||||
const { [sessionId]: _dropped, ...rest } = snapshot.rows
|
||||
snapshot = { rows: rest, observed: {} }
|
||||
}
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
return feed
|
||||
}
|
||||
|
||||
let sessionSeq = 0
|
||||
const mounted: MountedSidebar[] = []
|
||||
/** Owner sessions the stubbed `jobs.list` route was read for. */
|
||||
const jobListReads: string[] = []
|
||||
|
||||
function setViewport(width: number): void {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
|
||||
@@ -190,21 +239,9 @@ function mountSidebar(
|
||||
},
|
||||
}
|
||||
const feed = makeSessionFeed(initial)
|
||||
const jobs: Record<string, SidebarJobView[]> = {}
|
||||
// The jobs feed is polled through the plugin's own route (0.1.7 removed the
|
||||
// snapshot's jobs mirror): the registry below is what a test mutates.
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method !== 'jobs.list') throw new Error(`unexpected fetch ${String(url)}`)
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId?: string }
|
||||
const owner = body.sessionId ?? ''
|
||||
jobListReads.push(owner)
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, value: { jobs: jobs[owner] ?? [] } }),
|
||||
} as unknown as Response
|
||||
})
|
||||
// Background jobs arrive through the host's own client jobs service
|
||||
// (`ctx.jobs`): the feed below is what a test pushes rosters into.
|
||||
const jobs = makeJobsFeed()
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), autoOpenSubagent: true, autoOpenJobs: true })
|
||||
store.setSession(sessionId)
|
||||
@@ -221,7 +258,7 @@ function mountSidebar(
|
||||
betterSidebar: service,
|
||||
get: (name: string) => name === 'betterSidebar'
|
||||
? service
|
||||
: name === 'sidebarRight' ? column.face : undefined,
|
||||
: name === 'sidebarRight' ? column.face : name === 'jobs' ? jobs.service : undefined,
|
||||
} as unknown as Context
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
@@ -280,19 +317,20 @@ function flushSubagentDebounce(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one new job through the polled jobs route: let the mount-time
|
||||
* baseline read land, register the job, then advance one poll interval.
|
||||
* Deliver one new job: the roster frame the host's client service pushes. The
|
||||
* job's start stamp is AFTER the watch began, which is what makes it "new
|
||||
* work" rather than a job that was already running when the page mounted.
|
||||
*/
|
||||
async function publishJob(sidebar: MountedSidebar): Promise<void> {
|
||||
await flushFeeds()
|
||||
sidebar.jobs[sessionIdOf(sidebar)] = [{
|
||||
sidebar.jobs.publish(sessionIdOf(sidebar), [{
|
||||
id: 'bash-1',
|
||||
kind: 'bash',
|
||||
label: 'sleep 30',
|
||||
status: 'running',
|
||||
startedAt: 1_000,
|
||||
}]
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
startedAt: Date.now() + 1,
|
||||
}])
|
||||
await flushFeeds()
|
||||
}
|
||||
|
||||
/** The conversation the sidebar is bound to (its own store's session). */
|
||||
@@ -394,32 +432,39 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
it('a page that loads with jobs already running never triggers; the next NEW job does', async () => {
|
||||
const sidebar = mountSidebar(1024)
|
||||
// The conversation is already running work when the sidebar mounts: the
|
||||
// first successful read only ARMS the baseline, it is never "new work".
|
||||
sidebar.jobs[sidebar.sessionId] = [{
|
||||
// first frame only ARMS the baseline (and the watch clock filters a job
|
||||
// whose start stamp predates it), so it is never "new work".
|
||||
sidebar.jobs.publish(sidebar.sessionId, [{
|
||||
id: 'bash-0',
|
||||
kind: 'bash',
|
||||
label: 'already running',
|
||||
status: 'running',
|
||||
startedAt: 500,
|
||||
}]
|
||||
startedAt: Date.now() - 5_000,
|
||||
}])
|
||||
await flushFeeds()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(sidebar.surface.opens).toEqual([])
|
||||
// A second, genuinely new job id is what surfaces the Tasks page.
|
||||
sidebar.jobs[sidebar.sessionId] = [
|
||||
...sidebar.jobs[sidebar.sessionId] ?? [],
|
||||
{ id: 'bash-1', kind: 'bash', label: 'sleep 30', status: 'running', startedAt: 1_000 },
|
||||
]
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
sidebar.jobs.publish(sidebar.sessionId, [
|
||||
{
|
||||
id: 'bash-0',
|
||||
kind: 'bash',
|
||||
label: 'already running',
|
||||
status: 'running',
|
||||
startedAt: Date.now() - 5_000,
|
||||
},
|
||||
{ id: 'bash-1', kind: 'bash', label: 'sleep 30', status: 'running', startedAt: Date.now() + 1 },
|
||||
])
|
||||
await flushFeeds()
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
})
|
||||
|
||||
it('a session switch re-arms the job baseline instead of firing on the new session\'s jobs', async () => {
|
||||
const sidebar = mountSidebar(1024)
|
||||
sidebar.jobs['child'] = [{ id: 'bash-9', kind: 'bash', label: 'child work', status: 'running', startedAt: 10 }]
|
||||
sidebar.jobs.publish('child', [
|
||||
{ id: 'bash-9', kind: 'bash', label: 'child work', status: 'running', startedAt: Date.now() + 1 },
|
||||
])
|
||||
switchToChild(sidebar)
|
||||
await flushFeeds()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
// The child's pre-existing job belongs to the NEW baseline, not to "new
|
||||
// work in the session I am looking at" (the baseline resets on session).
|
||||
expect(sidebar.surface.opens).toEqual([])
|
||||
|
||||
+204
-152
@@ -2,12 +2,11 @@
|
||||
* Tasks page tests: the projection-backed topology (branch expansion inferred
|
||||
* from the child's OWN catalog, the `mode: 'unknown'` row, navigation through
|
||||
* the workspace face, catalog retry through the 0.1.7 refresh name) and the
|
||||
* background-jobs DRAWER — rows read through the plugin's `jobs.list` route
|
||||
* one session at a time (the registry's access fence admits a job to its OWNER
|
||||
* only), clicking a row peeks its output in an anchored popover (portaled to
|
||||
* document.body) through `jobs.output` with the owner scope, the kill button
|
||||
* needs a two-click confirm, settled rows offer no kill, and nothing polls
|
||||
* while the page is hidden.
|
||||
* background-jobs DRAWER — rows pushed by the HOST's client jobs service
|
||||
* (`ctx.jobs`, structurally faked here), output streamed for the one OPEN row
|
||||
* through `observe` (never the model's consuming cursor), the kill button
|
||||
* needing a two-click confirm and going through the service, and the watch
|
||||
* released while the page is hidden.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -17,12 +16,56 @@ import { renderRoot } from './test-utils.ts'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import type {
|
||||
Context,
|
||||
SidebarJobView,
|
||||
SidebarClientJobsService,
|
||||
SidebarJobsSnapshot,
|
||||
SidebarSessionList,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalogEntry,
|
||||
} from '../src/context-types.ts'
|
||||
|
||||
/**
|
||||
* A structurally faithful double of the host's client jobs service: a
|
||||
* subscribable snapshot (roster by watched session + observations), a
|
||||
* reference-counted `watchRows`, an `observe` that records its calls, and a
|
||||
* `kill` that records its arguments.
|
||||
*/
|
||||
function makeJobsService() {
|
||||
let snapshot: SidebarJobsSnapshot = { rows: {}, observed: {} }
|
||||
const listeners = new Set<() => void>()
|
||||
const watches: string[] = []
|
||||
const releases: string[] = []
|
||||
const observations: Array<{ sessionId: string | undefined; jobId: string }> = []
|
||||
const released: string[] = []
|
||||
const kills: Array<{ sessionId: string; jobId: string }> = []
|
||||
const service: SidebarClientJobsService = {
|
||||
state: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
watchRows: (sessionId: string) => {
|
||||
watches.push(sessionId)
|
||||
return () => { releases.push(sessionId) }
|
||||
},
|
||||
observe: (sessionId: string | undefined, jobId: string) => {
|
||||
observations.push({ sessionId, jobId })
|
||||
return () => { released.push(jobId) }
|
||||
},
|
||||
kill: async (sessionId: string, jobId: string) => {
|
||||
kills.push({ sessionId, jobId })
|
||||
},
|
||||
}
|
||||
const emit = (next: Partial<SidebarJobsSnapshot>): void => {
|
||||
snapshot = { ...snapshot, ...next }
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
return { service, emit, watches, releases, observations, released, kills }
|
||||
}
|
||||
|
||||
type JobsFake = ReturnType<typeof makeJobsService>
|
||||
|
||||
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
|
||||
function makeStore(initial: SidebarSessionList) {
|
||||
let snapshot = initial
|
||||
@@ -52,21 +95,21 @@ interface NavigationSpy {
|
||||
* workspace face: 0.1.6 moved main-view selection off `ISessions`, so a spy
|
||||
* left on `sessions.openSubagent` would never fire.
|
||||
*/
|
||||
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
|
||||
function makeCtx(store: Store, navigation?: NavigationSpy, jobs?: JobsFake): Context {
|
||||
return {
|
||||
sessions: { list: store },
|
||||
get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
|
||||
? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
|
||||
: undefined),
|
||||
get: (name: string) => {
|
||||
if (name === 'jobs') return jobs?.service
|
||||
if (name === 'uiWorkspace' && navigation !== undefined) {
|
||||
return { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
const outputCalls: Array<{ sessionId: string; id: string }> = []
|
||||
const killCalls: Array<{ sessionId: string; id: string }> = []
|
||||
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
|
||||
let jobsByOwner: Record<string, SidebarJobView[]> = {}
|
||||
/** The owner sessions the page actually read (the fence's unit of access). */
|
||||
let jobListCalls: string[] = []
|
||||
/** The jobs service the page reads (rebuilt per test in beforeEach). */
|
||||
let jobsFake: JobsFake
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => value } as unknown as Response
|
||||
@@ -100,40 +143,21 @@ function baseSnapshot(): SidebarSessionList {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
outputCalls.length = 0
|
||||
killCalls.length = 0
|
||||
jobListCalls = []
|
||||
jobsByOwner = {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
child: [{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }],
|
||||
}
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
jobsFake = makeJobsService()
|
||||
jobsFake.emit({
|
||||
rows: {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
child: [{
|
||||
id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed',
|
||||
startedAt: 2_000, finishedAt: 3_000, owner: 'child',
|
||||
}],
|
||||
},
|
||||
})
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request) => {
|
||||
const method = String(url).split('/').pop()
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
|
||||
if (method === 'subagents.live') {
|
||||
return jsonResponse({ ok: true, value: { live: {} } })
|
||||
}
|
||||
if (method === 'jobs.list') {
|
||||
const owner = body.sessionId ?? ''
|
||||
jobListCalls.push(owner)
|
||||
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[owner] ?? [] } })
|
||||
}
|
||||
if (method === 'jobs.output') {
|
||||
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
|
||||
// bash-9 stands for a job the model never read (read:false).
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
text: body.id === 'bash-9' ? '' : `output-of-${body.id ?? ''}`,
|
||||
truncated: false,
|
||||
read: body.id !== 'bash-9',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'jobs.kill') {
|
||||
killCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
|
||||
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
|
||||
}
|
||||
if (method === 'workflows.list') {
|
||||
return jsonResponse({ ok: true, value: { runs: [] } })
|
||||
}
|
||||
@@ -151,20 +175,22 @@ afterEach(() => {
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
/**
|
||||
* Flush the mount-time job reads: the poll fires immediately, and the request
|
||||
* → envelope → json → Promise.all chain settles over several microtask ticks.
|
||||
*/
|
||||
/** Flush the mount-time requests (routes + the service snapshot). */
|
||||
async function flushJobs(): Promise<void> {
|
||||
for (let tick = 0; tick < 4; tick++) {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the page and wait for its first job read to land. */
|
||||
async function renderPage(store: Store, active = true, navigation?: NavigationSpy) {
|
||||
/** Render the page with the jobs service wired in. */
|
||||
async function renderPage(
|
||||
store: Store,
|
||||
active = true,
|
||||
navigation?: NavigationSpy,
|
||||
jobs: JobsFake = jobsFake,
|
||||
) {
|
||||
const rendered = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active, ctx: makeCtx(store, navigation) }),
|
||||
createElement(SubagentView, { sessionId: 'root', active, ctx: makeCtx(store, navigation, jobs) }),
|
||||
)
|
||||
await flushJobs()
|
||||
return rendered
|
||||
@@ -276,99 +302,155 @@ describe('SubagentView background jobs', () => {
|
||||
expect(text).toContain('echo hi')
|
||||
expect(text).toContain('主会话')
|
||||
expect(text).toContain('子代理')
|
||||
// The fence admits a job to its owner only, so the tree costs one read per
|
||||
// tree session (and never a read of a session outside the tree).
|
||||
expect([...jobListCalls].sort()).toEqual(['child', 'root'])
|
||||
// The host shares one roster stream per session: the page watches every
|
||||
// session of the tree while it is on screen.
|
||||
expect([...jobsFake.watches].sort()).toEqual(['child', 'root'])
|
||||
// Only the running row offers a kill button.
|
||||
expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('renders nothing job-related when the tree has no jobs', async () => {
|
||||
jobsByOwner = {}
|
||||
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
|
||||
const { container, unmount } = await renderPage(store)
|
||||
it('renders nothing job-related on a deployment without the jobs service', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const bare = makeCtx(store)
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: bare }),
|
||||
)
|
||||
await flushJobs()
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('survives the job list emptying while mounted (hook-order regression #300)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
// Every job settles and drops: the section must vanish WITHOUT reordering
|
||||
// hooks — a hook below the empty-state return would crash React with
|
||||
// "Rendered fewer hooks than expected" (the minified #300 the sidebar
|
||||
// boundary surfaces with a retry button).
|
||||
jobsByOwner = {}
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
// And returning jobs must work too, with the same hook order.
|
||||
jobsByOwner = {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
}
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
it('releases every watch when the page goes away', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { unmount } = await renderPage(store)
|
||||
expect(jobsFake.releases).toEqual([])
|
||||
unmount()
|
||||
expect([...jobsFake.releases].sort()).toEqual(['child', 'root'])
|
||||
})
|
||||
|
||||
it('shows the selected job output in an anchored popover, dismissable', async () => {
|
||||
it('watches nothing while the page is hidden', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
// A mounted-but-hidden page renders from the last snapshot it holds; what
|
||||
// it must NOT do is hold a roster stream open.
|
||||
const { unmount } = await renderPage(store, false)
|
||||
expect(jobsFake.watches).toEqual([])
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('survives the roster emptying while mounted (hook-order regression #300)', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
// Every job settles and drops: the drawer must vanish WITHOUT reordering
|
||||
// hooks — a hook below the empty-state return would crash React with
|
||||
// "Rendered fewer hooks than expected" (the minified #300 the sidebar
|
||||
// boundary surfaces with a retry button).
|
||||
await act(async () => { jobsFake.emit({ rows: {} }) })
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
// And returning jobs must work too, with the same hook order.
|
||||
await act(async () => {
|
||||
jobsFake.emit({
|
||||
rows: { root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }] },
|
||||
})
|
||||
})
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('observes only the OPEN row and streams its output into the popover', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
// Nothing is observed until a row opens: the host streams per observer.
|
||||
expect(jobsFake.observations).toEqual([])
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// The peek request carries the OWNER session (the fence compares it).
|
||||
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
|
||||
// The popover is portaled to document.body (outside the tab container).
|
||||
expect(document.body.textContent).toContain('output-of-bash-1')
|
||||
expect(jobsFake.observations).toEqual([{ sessionId: 'root', jobId: 'bash-1' }])
|
||||
// The observed frames land in the portalled popover.
|
||||
await act(async () => {
|
||||
jobsFake.emit({
|
||||
observed: { 'bash-1': { text: 'building…\ndone', gapBefore: false, streaming: true } },
|
||||
})
|
||||
})
|
||||
expect(document.body.textContent).toContain('building…')
|
||||
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
|
||||
// Escape dismisses the popover.
|
||||
|
||||
// Switching rows releases the first observation and opens the second (its
|
||||
// owner session rides along — the kill and the stream both need it).
|
||||
const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement
|
||||
await act(async () => { second.click() })
|
||||
expect(jobsFake.released).toEqual(['bash-1'])
|
||||
expect(jobsFake.observations).toEqual([
|
||||
{ sessionId: 'root', jobId: 'bash-1' },
|
||||
{ sessionId: 'child', jobId: 'bash-2' },
|
||||
])
|
||||
expect(document.body.textContent).not.toContain('building…')
|
||||
|
||||
// Escape dismisses the popover AND releases the observation.
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
})
|
||||
expect(document.body.textContent).not.toContain('output-of-bash-1')
|
||||
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(0)
|
||||
expect(jobsFake.released).toContain('bash-2')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('switches the single output popover between rows', async () => {
|
||||
it('flags a gap and a producer error in the observed stream', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { first.click() })
|
||||
expect(document.body.textContent).toContain('output-of-bash-1')
|
||||
const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement
|
||||
await act(async () => { second.click() })
|
||||
// One popover, now fed by the second job (its owner session scopes the replay).
|
||||
expect(outputCalls).toEqual([
|
||||
{ sessionId: 'root', id: 'bash-1' },
|
||||
{ sessionId: 'child', id: 'bash-2' },
|
||||
])
|
||||
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
|
||||
expect(document.body.textContent).not.toContain('output-of-bash-1')
|
||||
expect(document.body.textContent).toContain('output-of-bash-2')
|
||||
await act(async () => {
|
||||
(container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement).click()
|
||||
})
|
||||
await act(async () => {
|
||||
jobsFake.emit({
|
||||
observed: {
|
||||
'bash-1': { text: 'tail only', gapBefore: true, streaming: false, error: 'writer died' },
|
||||
},
|
||||
})
|
||||
})
|
||||
const body = document.body.textContent ?? ''
|
||||
// The dropped-frame notice and the producer error both surface (the
|
||||
// plugin's own copy for each).
|
||||
expect(body).toContain('输出过长,已截断显示')
|
||||
expect(body).toContain('输出读取失败')
|
||||
expect(body).toContain('tail only')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('explains when the model has not read the job yet', async () => {
|
||||
jobsByOwner = {
|
||||
root: [
|
||||
...jobsByOwner.root ?? [],
|
||||
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
|
||||
],
|
||||
}
|
||||
it('kills a live job through the service only after the two-click confirm', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// read:false → the popover explains the output awaits the model's job_output
|
||||
// (never the model's cursor, so there is nothing to steal yet).
|
||||
expect(document.body.textContent).toContain('等待模型读取该任务的输出')
|
||||
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
|
||||
await act(async () => { kill.click() })
|
||||
// First click only arms the confirm — nothing reaches the registry.
|
||||
expect(jobsFake.kills).toEqual([])
|
||||
const confirm = container.querySelector('button[aria-label="再次点击确认终止"]')
|
||||
expect(confirm).not.toBeNull()
|
||||
await act(async () => { (confirm as HTMLButtonElement).click() })
|
||||
expect(jobsFake.kills).toEqual([{ sessionId: 'root', jobId: 'bash-1' }])
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('drops rows owned by a session outside the tree', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
// An unowned job is visible to every watcher, so it stays; a job owned by
|
||||
// a session this tree does not contain must not appear.
|
||||
await act(async () => {
|
||||
jobsFake.emit({
|
||||
rows: {
|
||||
root: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
|
||||
{ id: 'bash-7', kind: 'bash', label: 'foreign cmd', status: 'running', startedAt: 1_100, owner: 'elsewhere' },
|
||||
{ id: 'bash-8', kind: 'bash', label: 'unowned cmd', status: 'running', startedAt: 1_200 },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const text = container.textContent ?? ''
|
||||
expect(text).toContain('sleep 300')
|
||||
expect(text).toContain('unowned cmd')
|
||||
expect(text).not.toContain('foreign cmd')
|
||||
unmount()
|
||||
})
|
||||
|
||||
@@ -381,47 +463,17 @@ describe('SubagentView background jobs', () => {
|
||||
startedAt: 1_000 + index,
|
||||
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
|
||||
}))
|
||||
jobsByOwner = { root: many }
|
||||
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
|
||||
await act(async () => { jobsFake.emit({ rows: { root: many } }) })
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
|
||||
// One agent: the drawer is open by default (the auto-collapse rule counts
|
||||
// AGENTS, not jobs — see the 8-child case in tasks-page.spec.tsx).
|
||||
expect(container.textContent).toContain('后台任务')
|
||||
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
|
||||
// Clicking a row anywhere in the long list still feeds the single popover.
|
||||
const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-69' }])
|
||||
expect(document.body.textContent).toContain('output-of-bash-69')
|
||||
expect(jobsFake.observations).toEqual([{ sessionId: 'root', jobId: 'bash-69' }])
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('kills a live job only after the two-click confirm', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
|
||||
await act(async () => { kill.click() })
|
||||
// First click only arms the confirm — no request leaves the page.
|
||||
expect(killCalls).toEqual([])
|
||||
const confirm = container.querySelector('button[aria-label="再次点击确认终止"]')
|
||||
expect(confirm).not.toBeNull()
|
||||
await act(async () => { (confirm as HTMLButtonElement).click() })
|
||||
expect(killCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not read jobs or poll output while the page is hidden', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store, false)
|
||||
// A hidden page issues no job read at all (it also has no rows to show).
|
||||
expect(jobListCalls).toEqual([])
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(jobListCalls).toEqual([])
|
||||
expect(outputCalls).toEqual([])
|
||||
unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+24
-34
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Pure-helper tests for the Subagent page's background-job section:
|
||||
* tree-membership collection, ordering, and status presentation mapping.
|
||||
* Pure-helper tests for the Tasks page's background-job presentation:
|
||||
* tree-membership, ordering, status mapping, and the auto-open trigger's
|
||||
* "is this really new work?" rule.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
detectNewJob,
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
@@ -61,31 +61,6 @@ describe('treeSessionIds', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectTreeJobs', () => {
|
||||
it('collects jobs of the whole tree with owner titles, ignoring outside sessions', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
}
|
||||
// The map is the client's per-owner read of `jobs.list` (the registry's
|
||||
// fence admits a job to its owner session only), keyed by that owner.
|
||||
const jobsByOwner = {
|
||||
root: [job('bash-1')],
|
||||
child: [job('bash-2', { status: 'completed', finishedAt: 2_000 })],
|
||||
stranger: [job('bash-9')],
|
||||
}
|
||||
const rows = collectTreeJobs(byId, jobsByOwner, 'root')
|
||||
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.job.id]))
|
||||
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
|
||||
})
|
||||
|
||||
it('returns an empty list for an absent read or empty sets', () => {
|
||||
const byId = { root: summary('root') }
|
||||
expect(collectTreeJobs(byId, undefined, 'root')).toEqual([])
|
||||
expect(collectTreeJobs(byId, {}, 'root')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderJobs', () => {
|
||||
it('puts live rows first in start order, then settled rows newest-first', () => {
|
||||
const row = (id: string, status: SidebarJobStatus, startedAt: number, finishedAt?: number) => ({
|
||||
@@ -138,10 +113,11 @@ describe('status presentation helpers', () => {
|
||||
describe('detectNewJob', () => {
|
||||
/**
|
||||
* The baseline rule the auto-open trigger adds on top of this helper: the
|
||||
* FIRST list a page reads only arms the baseline (the poller keeps
|
||||
* `prev === undefined` until a read succeeds), so a conversation that is
|
||||
* already running jobs when the page loads never pops the Tasks page. The
|
||||
* helper itself is pure over two lists.
|
||||
* FIRST frame a page observes only arms the baseline, so a conversation that
|
||||
* is already running jobs when the page loads never pops the Tasks page. The
|
||||
* helper itself is pure over two frames — plus the WATCH CLOCK (`since`),
|
||||
* which is what keeps that promise now that the roster is a push stream
|
||||
* whose empty frames are indistinguishable from "not delivered yet".
|
||||
*/
|
||||
it('fires on EVERY job id the previous list lacked, and on nothing else', () => {
|
||||
// A new id appears (the first job, then another one alongside it).
|
||||
@@ -156,8 +132,22 @@ describe('detectNewJob', () => {
|
||||
// are not new work.
|
||||
expect(detectNewJob([job('bash-1')], [job('bash-1')])).toBe(false)
|
||||
expect(detectNewJob([job('bash-1'), job('bash-2')], [job('bash-2')])).toBe(false)
|
||||
// The first list of a fresh page is compared against itself by the caller
|
||||
// (prev stays undefined until a read succeeds) — the helper's quiet case.
|
||||
// The first frame of a fresh page is compared against itself by the caller
|
||||
// (prev stays undefined until a frame lands) — the helper's quiet case.
|
||||
expect(detectNewJob([], [])).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores work that started before the watcher opened', () => {
|
||||
const watchStart = 10_000
|
||||
// The page mounts while a job already runs: its id is new to the id-set
|
||||
// diff, but its start time predates the watch.
|
||||
expect(detectNewJob([], [job('bash-1', { startedAt: 500 })], watchStart)).toBe(false)
|
||||
// Work the agent starts after the watcher opened still triggers.
|
||||
expect(detectNewJob([], [job('bash-1', { startedAt: 10_000 })], watchStart)).toBe(true)
|
||||
expect(detectNewJob([], [job('bash-1', { startedAt: 12_000 })], watchStart)).toBe(true)
|
||||
// An already-seen id never triggers, however new its stamp.
|
||||
expect(detectNewJob([job('bash-1')], [job('bash-1', { startedAt: 20_000 })], watchStart)).toBe(false)
|
||||
// Omitting the clock keeps the old id-set behaviour.
|
||||
expect(detectNewJob([], [job('bash-1', { startedAt: 0 })])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,9 @@ function makeCtx(store: Store, historySpy: ReturnType<typeof vi.fn>): Context {
|
||||
subagents: { history: historySpy },
|
||||
},
|
||||
},
|
||||
// No host jobs service in this harness: the page renders no jobs surface
|
||||
// (and therefore issues no jobs request).
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
@@ -135,7 +138,6 @@ describe('SubagentView live polling', () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
@@ -171,7 +173,6 @@ describe('SubagentView live polling', () => {
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return new Promise<Response>((resolve) => { resolveFirst = resolve })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
@@ -210,7 +211,6 @@ describe('SubagentView live polling', () => {
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return new Promise<Response>((resolve) => { resolveFirst = resolve })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
@@ -254,7 +254,6 @@ describe('SubagentView live polling', () => {
|
||||
: {}
|
||||
return jsonResponse({ ok: true, value: { live } })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
|
||||
+50
-12
@@ -12,7 +12,8 @@ import { renderRoot } from './test-utils.ts'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import type {
|
||||
Context,
|
||||
SidebarJobView,
|
||||
SidebarClientJobsService,
|
||||
SidebarJobsSnapshot,
|
||||
SidebarProjectionSnapshot,
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
@@ -44,6 +45,41 @@ interface NavigationSpy {
|
||||
opened: Array<SidebarSubagentAddress | string>
|
||||
}
|
||||
|
||||
/**
|
||||
* A structurally faithful double of the host's client jobs service: a
|
||||
* subscribable snapshot, a reference-counted `watchRows`, an `observe` that
|
||||
* records its calls, and a `kill` that records its arguments.
|
||||
*/
|
||||
function makeJobsService() {
|
||||
let snapshot: SidebarJobsSnapshot = { rows: {}, observed: {} }
|
||||
const listeners = new Set<() => void>()
|
||||
const observations: Array<{ sessionId: string | undefined; jobId: string }> = []
|
||||
const kills: Array<{ sessionId: string; jobId: string }> = []
|
||||
const service: SidebarClientJobsService = {
|
||||
state: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
watchRows: () => () => {},
|
||||
observe: (sessionId: string | undefined, jobId: string) => {
|
||||
observations.push({ sessionId, jobId })
|
||||
return () => {}
|
||||
},
|
||||
kill: async (sessionId: string, jobId: string) => { kills.push({ sessionId, jobId }) },
|
||||
}
|
||||
const emit = (next: Partial<SidebarJobsSnapshot>): void => {
|
||||
snapshot = { ...snapshot, ...next }
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
return { service, emit, observations, kills }
|
||||
}
|
||||
|
||||
/** The jobs service the page reads (rebuilt per test in beforeEach). */
|
||||
let jobsFake: ReturnType<typeof makeJobsService>
|
||||
|
||||
/** The client context face SubagentView touches. */
|
||||
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
|
||||
return {
|
||||
@@ -51,9 +87,13 @@ function makeCtx(store: Store, navigation?: NavigationSpy): Context {
|
||||
list: store,
|
||||
refreshProjections: async () => {},
|
||||
},
|
||||
get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
|
||||
? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
|
||||
: undefined),
|
||||
get: (name: string) => {
|
||||
if (name === 'jobs') return jobsFake.service
|
||||
if (name === 'uiWorkspace' && navigation !== undefined) {
|
||||
return { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
@@ -64,8 +104,6 @@ function jsonResponse(value: unknown): Response {
|
||||
let teamPayload: unknown = { available: false }
|
||||
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
|
||||
const fetchedMethods: string[] = []
|
||||
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
|
||||
let jobsByOwner: Record<string, SidebarJobView[]> = {}
|
||||
/** The `subagents.live` payload the stub answers with (session id → live view). */
|
||||
let livePayload: Record<string, unknown> = {}
|
||||
/** The folded workflow runs the stub answers `workflows.list` with. */
|
||||
@@ -77,9 +115,12 @@ beforeEach(() => {
|
||||
runsPayload = []
|
||||
teamMutations.length = 0
|
||||
fetchedMethods.length = 0
|
||||
jobsByOwner = {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
}
|
||||
jobsFake = makeJobsService()
|
||||
jobsFake.emit({
|
||||
rows: {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
},
|
||||
})
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
fetchedMethods.push(method ?? '')
|
||||
@@ -87,9 +128,6 @@ beforeEach(() => {
|
||||
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: livePayload } })
|
||||
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: runsPayload } })
|
||||
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
|
||||
if (method === 'jobs.list') {
|
||||
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[String(body.sessionId ?? '')] ?? [] } })
|
||||
}
|
||||
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
|
||||
teamMutations.push({ method: method ?? '', body })
|
||||
return jsonResponse({
|
||||
|
||||
Reference in New Issue
Block a user