feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and one-shot subagent since it landed, but only the model could read it: a human at the Web client could not see that a build was running, tell a finished task from a stuck one, or find its outcome anywhere but the `run_in_background` tool card that printed an id and never updated. Task state now reaches the browser as one whole-snapshot `session/tasks` mux frame per session, pushed at every registry commit that changes what that session can see. `TaskService` gains `onTasksChanged`, which is owner-granular because owner-disposal removal is a change no per-task record can express. The carrier reads the exact owner the listener hands it, so a push stays correct while that scope tears down, and reads the baseline through the non-resuming `ctx.agents.get` so listing never revives a cold session. The client keeps a last-wins mirror on `SessionListState`, and a new `dsh-client-ui-task` package renders it beside the subagent catalog — rendering nothing at all until the session has a task, so an ordinary conversation grows no new chrome. Streamed per-task output and human-initiated cancellation are separate phases; the note records why neither has to undo this channel, and why no Web path may call the consuming `ctx.tasks.read()`.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: a3c9f214690144ec0f39a8690e4fd346f5e315e2
|
||||
README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340
|
||||
README.md: edf51dd1c15c61a03102003eb921a0e21b1f0fa8
|
||||
README.zh.md: 6b8bfa0459167b87c5b2848eb683cabf7ce91444
|
||||
|
||||
@@ -36,6 +36,8 @@ Session model routing is a session-domain contract. `session.models` returns the
|
||||
|
||||
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
|
||||
|
||||
Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
@@ -40,6 +40,9 @@ import {
|
||||
} from './api/session-search.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('tasks')` to the background task registry.
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// GoalError narrows domain rejections to their stable codes at the wire boundary.
|
||||
@@ -261,6 +264,22 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project registry snapshots onto the wire view, dropping the three internal
|
||||
* fields {@link TaskView} documents as absent.
|
||||
*/
|
||||
function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
|
||||
return snapshots.map(task => ({
|
||||
id: task.id,
|
||||
kind: task.kind,
|
||||
label: task.label,
|
||||
status: task.status,
|
||||
...task.detail === undefined ? {} : { detail: task.detail },
|
||||
startedAt: task.startedAt,
|
||||
...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's conversation has started: no turn has run yet (a
|
||||
* turn is one model-loop execution). Standalone plugin events — command
|
||||
@@ -2586,6 +2605,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
|
||||
}
|
||||
}
|
||||
// Background-task baseline. `ctx.agents.get` is the non-resuming read:
|
||||
// a session with no live Agent owns no tasks, so it correctly sees only
|
||||
// the unowned ones, and listing never revives a cold session. An empty
|
||||
// set sends nothing — absence is how the client reads "no tasks".
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks !== undefined) {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
const views = taskViews(tasks.list(ctx.agents.get(session.id)))
|
||||
if (views.length > 0) {
|
||||
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per-session open-call table for result-view pairing. Bounded by the
|
||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||
// opened mid-turn) backscans the session's in-memory events instead.
|
||||
@@ -2614,6 +2646,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
openCalls.delete(session.id)
|
||||
}),
|
||||
...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
|
||||
if (owner !== undefined) {
|
||||
// The exact owner instance the fence compares against, so the
|
||||
// push stays correct even while that Agent's scope is tearing
|
||||
// down and a lookup by id would already miss.
|
||||
queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
|
||||
return
|
||||
}
|
||||
// An unowned task is visible to every caller, so every subscribed
|
||||
// session's set changed with it.
|
||||
for (const session of ctx.sessions.list()) {
|
||||
queue.push(frame({
|
||||
type: 'session/tasks',
|
||||
sessionId: session.id,
|
||||
tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
|
||||
}))
|
||||
}
|
||||
})],
|
||||
]
|
||||
return queue.iterate(signal, () => {
|
||||
muxQueues.delete(queue)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { taskViewSchema } from './tasks.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
@@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }),
|
||||
// value stays wide: it already passed its unit's own schema on the host,
|
||||
// and deep-validating here would import every domain's schema into the carrier.
|
||||
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
|
||||
// Client-side consumers take the render-intent vocabulary from the contract;
|
||||
@@ -81,6 +82,20 @@ export type MuxFrame =
|
||||
* in QueueDock, while pending steering renders at the conversation tail.
|
||||
*/
|
||||
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
|
||||
/**
|
||||
* Complete set of background tasks this session can see, after every registry
|
||||
* commit that changes it: registration, the stopping transition, settlement,
|
||||
* and owner-disposal removal. The registry is process-local and holds no
|
||||
* durable event, so — exactly like `session/queue` — the whole snapshot is
|
||||
* what makes a start, a kill, a reconnect, and a second tab converge on one
|
||||
* authoritative value.
|
||||
*
|
||||
* Sent as a subscription baseline only for a session that currently has
|
||||
* tasks; an absent key means an empty set. A change that empties the set
|
||||
* still sends `[]`, since that transition is the only one absence cannot
|
||||
* express.
|
||||
*/
|
||||
| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] }
|
||||
/**
|
||||
* One projection unit's finished value changed (session-projection RFC).
|
||||
* Live push state, never logged — replay recomputes on the host (the
|
||||
|
||||
@@ -44,6 +44,7 @@ export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type {
|
||||
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
|
||||
} from './subagents.ts'
|
||||
export type { TaskView } from './tasks.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
|
||||
33
packages/host/apiproxy/src/api/tasks.schema.ts
Normal file
33
packages/host/apiproxy/src/api/tasks.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* tasks domain zod schemas: the branded task id and the wire view carried by
|
||||
* `session/tasks` frames.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
|
||||
/** TaskId: one brand cast after non-empty string validation. */
|
||||
export const taskIdSchema = z.string().min(1) as unknown as z.ZodType<TaskId>
|
||||
|
||||
/**
|
||||
* One wire task view. `kind` stays an open string because producer plugins
|
||||
* extend the registry's kind map by declaration merging, so the closed set is
|
||||
* not knowable at this boundary.
|
||||
*/
|
||||
export const taskViewSchema = z.object({
|
||||
id: taskIdSchema,
|
||||
kind: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
status: z.union([
|
||||
z.literal('running'),
|
||||
z.literal('stopping'),
|
||||
z.literal('completed'),
|
||||
z.literal('killed'),
|
||||
z.literal('failed'),
|
||||
]),
|
||||
detail: z.string().optional(),
|
||||
startedAt: z.number().int().nonnegative(),
|
||||
finishedAt: z.number().int().nonnegative().optional(),
|
||||
}) satisfies z.ZodType<Wire<TaskView>>
|
||||
36
packages/host/apiproxy/src/api/tasks.ts
Normal file
36
packages/host/apiproxy/src/api/tasks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Browser-safe background-task domain contract. The registry's live records
|
||||
* never cross the wire; a view is the subset a human list needs, minted fresh
|
||||
* per push.
|
||||
*/
|
||||
|
||||
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
|
||||
|
||||
/**
|
||||
* One background task as the client sees it.
|
||||
*
|
||||
* Three registry fields are deliberately absent. `ownerSession` is redundant
|
||||
* beside the frame's own `sessionId`; `reported` is an internal notice-delivery
|
||||
* bit with no user meaning; `outputLimitBytes` is producer-owned model
|
||||
* presentation policy that never reaches a human surface.
|
||||
*/
|
||||
export interface TaskView {
|
||||
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
|
||||
id: TaskId
|
||||
/**
|
||||
* Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare
|
||||
* string because producer plugins extend the kind map by declaration merging,
|
||||
* so no client build can enumerate the closed set.
|
||||
*/
|
||||
kind: string
|
||||
/** Producer-supplied one-line label: the command, or the delegation description. */
|
||||
label: string
|
||||
/** Current lifecycle state. */
|
||||
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
/** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */
|
||||
detail?: string
|
||||
/** Epoch ms when the task was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the task settled; absent while live. */
|
||||
finishedAt?: number
|
||||
}
|
||||
206
packages/host/apiproxy/tests/api-proxy-tasks.spec.ts
Normal file
206
packages/host/apiproxy/tests/api-proxy-tasks.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Background-task carrier paths of the host ApiProxy: the subscription
|
||||
* baseline is sent only for a session that has tasks, every registry change
|
||||
* pushes that owner's whole set, an unowned change fans out to every
|
||||
* subscribed session, the projection drops the three internal snapshot
|
||||
* fields, a composition without `ctx.tasks` emits nothing, and listing never
|
||||
* resumes a cold session.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
|
||||
|
||||
/**
|
||||
* A producer whose settlement the test drives. `cancel` deliberately does not
|
||||
* settle, so a kill is observable as the distinct `stopping` step before the
|
||||
* test supplies the terminal outcome and its detail.
|
||||
*/
|
||||
function producer(label = 'sleep 60') {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
const spec = {
|
||||
kind: 'bash' as const,
|
||||
label,
|
||||
run: () => ({
|
||||
cancel: () => {},
|
||||
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
|
||||
}),
|
||||
}
|
||||
return { spec, settle: (outcome: TaskOutcome) => { settle(outcome) } }
|
||||
}
|
||||
|
||||
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) {
|
||||
await ctx.plugin(LocalTaskService)
|
||||
ctx.tasks.attachSurface('api-proxy-test')
|
||||
}
|
||||
const session = ctx.sessions.create()
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, session, agent }
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
|
||||
async function collect(
|
||||
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
count: number,
|
||||
abort: AbortController,
|
||||
): Promise<TaskFrame[]> {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const envelope of iterable) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort()
|
||||
}
|
||||
return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks')
|
||||
}
|
||||
|
||||
describe('session/tasks subscription baseline', () => {
|
||||
it('is omitted for a session with no tasks — absence is the empty set', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort()
|
||||
}
|
||||
})()
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true)
|
||||
void session
|
||||
})
|
||||
|
||||
it('carries the live set for a session that already has tasks when the stream opens', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent })
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal)
|
||||
const [baseline] = await collect(stream, 1, abort)
|
||||
expect(baseline?.sessionId).toBe(session.id)
|
||||
expect(baseline?.tasks).toHaveLength(1)
|
||||
const [task] = baseline?.tasks ?? []
|
||||
expect(task?.startedAt).toBeTypeOf('number')
|
||||
expect({ ...task, startedAt: 0 }).toEqual({
|
||||
id: 'bash-1',
|
||||
kind: 'bash',
|
||||
label: 'pnpm run build',
|
||||
status: 'running',
|
||||
startedAt: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks change pushes', () => {
|
||||
it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 3, abort)
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.tasks.kill(id, agent, 'test')
|
||||
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
|
||||
expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed'])
|
||||
// Terminal detail rides the same whole-set push; no separate signal.
|
||||
expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM')
|
||||
expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => {
|
||||
const { ctx, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
|
||||
|
||||
const [frame] = await collected
|
||||
const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {})
|
||||
expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status'])
|
||||
})
|
||||
|
||||
it('fans an unowned change out to every subscribed session', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const second = ctx.sessions.create()
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 2, abort)
|
||||
|
||||
ctx.tasks.start(producer('open to every caller').spec)
|
||||
|
||||
const frames = await collected
|
||||
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
|
||||
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
|
||||
for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller')
|
||||
})
|
||||
|
||||
it('serves a cold session the unowned set without resuming it', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const coldId = SessionId('session-cold-tasks')
|
||||
let loaded = false
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
||||
locate: () => undefined,
|
||||
load: () => { loaded = true; throw new Error('task listing must not load a cold log') },
|
||||
} as never)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
await collected
|
||||
expect(loaded).toBe(false)
|
||||
expect(ctx.agents.get(coldId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks without the registry', () => {
|
||||
it('emits no frames at all, so the client renders no entry point', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort()
|
||||
}
|
||||
})()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -433,6 +433,11 @@ describe('events frame schemas', () => {
|
||||
},
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 },
|
||||
{ id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 },
|
||||
] },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
@@ -441,6 +446,14 @@ describe('events frame schemas', () => {
|
||||
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
|
||||
// A producer kind stays an open string, but the closed status set and
|
||||
// the identity/label bounds are the carrier's own wire contract.
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
|
||||
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
|
||||
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
||||
})
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user