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:
@@ -176,6 +176,10 @@
|
||||
- id: ui-subagent
|
||||
name: '@deepseek-ai/dsh-client-ui-subagent'
|
||||
|
||||
# Background tasks: the session-header list over the tasksBySession mirror.
|
||||
- id: ui-task
|
||||
name: '@deepseek-ai/dsh-client-ui-task'
|
||||
|
||||
# Goal surface: GoalBar in the input dock over the goal session projection.
|
||||
- id: ui-goal
|
||||
name: '@deepseek-ai/dsh-client-ui-goal'
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-task": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
|
||||
@@ -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/client/README.md
|
||||
README.md: b6fa426fbe541e2b22d2bf5f19d4397361cf0899
|
||||
README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64
|
||||
README.md: 347a42e0671ad0ae1da6c5488f94f387e85fcf99
|
||||
README.zh.md: 596ce3910430a183a9750c94b83cb79f43ce037f
|
||||
|
||||
@@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. |
|
||||
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
|
||||
| [`ui-task/`](ui-task/README.md) | Lists this session's background tasks in the conversation header. |
|
||||
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
|
||||
@@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 |
|
||||
| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 |
|
||||
| [`ui-task/`](ui-task/README.md) | 在会话标题栏列出当前会话的后台任务。 |
|
||||
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
|
||||
@@ -17,6 +17,7 @@ export type {
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
TaskView,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type {
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
TaskView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -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/client/runtime/README.md
|
||||
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
|
||||
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774
|
||||
README.md: 5abe72767aa76d5690b3f287391bc8924bc62d03
|
||||
README.zh.md: 78024fa095a5bcddc50ee121eec64e887f241ba7
|
||||
|
||||
@@ -24,6 +24,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
|
||||
|
||||
`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
@@ -24,6 +24,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
|
||||
|
||||
`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
@@ -36,7 +36,7 @@ export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
|
||||
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -47,6 +47,8 @@ export interface SessionListSnapshot {
|
||||
phase: SessionListPhase
|
||||
error: RpcError | null
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/** Background tasks per session; an absent key is an empty set. */
|
||||
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
@@ -137,6 +139,11 @@ export class SessionManager {
|
||||
private readonly catalogStale = new Set<SessionId>()
|
||||
private readonly openCatalogs = new Set<SessionId>()
|
||||
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
||||
/**
|
||||
* Background tasks per session, last-wins from `session/tasks`. An empty set
|
||||
* is stored as an absent key, so absence and `[]` are one representation.
|
||||
*/
|
||||
private readonly tasksBySession = new Map<SessionId, readonly TaskView[]>()
|
||||
|
||||
private selected: SessionId | undefined
|
||||
|
||||
@@ -657,10 +664,23 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/tasks') {
|
||||
// Whole-set snapshot, so last-wins with no reconciliation. The Host omits
|
||||
// the baseline for an empty set, which is the same fact an emptying change
|
||||
// reports as `[]` — both land as an absent key.
|
||||
if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId)
|
||||
else this.tasksBySession.set(frame.sessionId, frame.tasks)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
// Same re-baseline reasoning as the queue below: this generation sends a
|
||||
// task baseline only when the set is non-empty, so a mirror kept from the
|
||||
// previous generation would survive as a phantom list.
|
||||
this.tasksBySession.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
// New mux-generation baseline: discard the previous queue snapshot.
|
||||
// The host omits session/queue when the live queue is empty, so retaining
|
||||
@@ -770,6 +790,11 @@ export class SessionManager {
|
||||
}
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
// Owner disposal already dropped these registry-side, but that lands on
|
||||
// the mux stream while this frame rides the host stream, so the two have
|
||||
// no relative order. Clearing here makes a detached Activation's rows
|
||||
// disappear whichever arrives first.
|
||||
this.tasksBySession.delete(frame.sessionId)
|
||||
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
|
||||
// A pull already in flight was requested before this removal and can
|
||||
// carry the pre-removal parentAvailable:true, which would resurrect
|
||||
@@ -1006,6 +1031,7 @@ export class SessionManager {
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
subagentsByParent: Object.fromEntries(this.catalogs),
|
||||
tasksBySession: Object.fromEntries(this.tasksBySession),
|
||||
currentAddress: current === undefined ? undefined : this.addresses.get(current),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -80,6 +80,12 @@ export interface SessionListState {
|
||||
phase: SessionListPhase
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/**
|
||||
* Background tasks each session can see, mirrored last-wins from
|
||||
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
|
||||
* for a session without tasks — so consumers read absence, never a sentinel.
|
||||
*/
|
||||
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
|
||||
/** Current session's catalog-derived address, absent on ordinary navigation. */
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
@@ -271,7 +277,7 @@ export class SessionsService implements ISessions {
|
||||
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -606,7 +612,7 @@ export class SessionsService implements ISessions {
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const {
|
||||
items, current, phase, subagentsByParent, currentAddress,
|
||||
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
|
||||
} = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
@@ -675,7 +681,7 @@ export class SessionsService implements ISessions {
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
})
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
|
||||
this.pruneScopes()
|
||||
}
|
||||
|
||||
|
||||
@@ -1110,3 +1110,60 @@ describe('completed reminder', () => {
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background-task mirror', () => {
|
||||
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
|
||||
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
|
||||
})
|
||||
const tasksFrame = (sessionId: SessionId, tasks: unknown[]) =>
|
||||
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
|
||||
|
||||
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
|
||||
const first = manager.getListSnapshot().tasksBySession
|
||||
expect(first[S1]).toEqual([view()])
|
||||
expect(first[S2]?.[0]?.label).toBe('other')
|
||||
|
||||
// Last-wins: the newer whole set replaces, it does not merge.
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })]))
|
||||
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })])
|
||||
})
|
||||
|
||||
it('stores an emptied set as an absent key so absence and [] read alike', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, []))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 's' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
|
||||
})
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the rows when the session is removed, whichever stream lands first', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies list subscribers so an open header re-renders without a poll', async () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const seen = vi.fn()
|
||||
manager.subscribe(seen)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
// The notifier batches on a microtask; the frame itself is already applied.
|
||||
await Promise.resolve()
|
||||
expect(seen).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,7 +192,7 @@ export class TestSessions implements ISessions {
|
||||
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
this.channel = new SessionProvideChannel({
|
||||
rebuildBundles: () => {
|
||||
|
||||
@@ -110,7 +110,7 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ afterEach(() => {
|
||||
|
||||
function emptySessions() {
|
||||
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
@@ -149,7 +149,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
|
||||
@@ -109,7 +109,7 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -37,7 +37,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -123,7 +123,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -107,7 +107,7 @@ function mount(
|
||||
ids: listed ? [root, SID] : [root],
|
||||
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
|
||||
current: SID,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
|
||||
@@ -61,6 +61,7 @@ function props(
|
||||
},
|
||||
current: PARENT, phase: 'ready',
|
||||
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
|
||||
tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
} satisfies SessionListState
|
||||
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
||||
|
||||
6
packages/client/ui-task/README.i18n.yaml
Normal file
6
packages/client/ui-task/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/client/ui-task/README.md
|
||||
README.md: 318984808838cb7a336a4c34c95a2f27db578423
|
||||
README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0
|
||||
24
packages/client/ui-task/README.md
Normal file
24
packages/client/ui-task/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-client-ui-task
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web background-task feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.tasks` records this session can see. The data arrives entirely through the `tasksBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/tasks` frames, so this package issues no RPC and holds no state beyond popover visibility.
|
||||
|
||||
The trigger renders only when the session has at least one task, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished tasks a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches.
|
||||
|
||||
Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed task's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to.
|
||||
|
||||
Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last task disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `task` locale namespace. The behavior is specified by the [Web background-task display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same tasks stays with [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md).
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; the package never assembles or sends provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running.
|
||||
- **The list is not the registry's own set** — it shows what the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model.
|
||||
24
packages/client/ui-task/README.zh.md
Normal file
24
packages/client/ui-task/README.zh.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-client-ui-task
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.tasks` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.md) 从 `session/tasks` 帧折叠出的 `tasksBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。
|
||||
|
||||
只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。
|
||||
|
||||
终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。
|
||||
|
||||
Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `task` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md) 规定。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md)。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;本包从不组装或发送 provider 请求。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。
|
||||
- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。
|
||||
68
packages/client/ui-task/package.json
Normal file
68
packages/client/ui-task/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-task",
|
||||
"description": "Session-header background-task list: live registry state mirrored from session/tasks frames",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
116
packages/client/ui-task/src/client/TaskListAction.module.css
Normal file
116
packages/client/ui-task/src/client/TaskListAction.module.css
Normal file
@@ -0,0 +1,116 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-height: 28px;
|
||||
padding: 3px 2px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.trigger svg {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.triggerDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 336px;
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
max-height: min(420px, calc(100vh - 140px));
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.rowSettled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.rowDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.kind {
|
||||
flex: none;
|
||||
padding: 0 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--dsw-alias-fill-l2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--dsw-font-mono);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status,
|
||||
.duration {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
185
packages/client/ui-task/src/client/TaskListAction.tsx
Normal file
185
packages/client/ui-task/src/client/TaskListAction.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import type { TaskView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconChevronDownOutline14, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './TaskListAction.module.css'
|
||||
|
||||
/** Full props for the session-header background-task action. */
|
||||
export type TaskListActionProps =
|
||||
PropsRuntime<'conversation.session.header.actions'> & PropsLocale<typeof NS>
|
||||
|
||||
/** Stable empty list so a session with no tasks keeps one array identity. */
|
||||
const NO_TASKS: readonly TaskView[] = []
|
||||
|
||||
/** A task the registry still holds open, and whose duration therefore ticks. */
|
||||
function isLive(task: TaskView): boolean {
|
||||
return task.status === 'running' || task.status === 'stopping'
|
||||
}
|
||||
|
||||
/** Closed-union exhaustiveness fence for the wire status set. */
|
||||
/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unhandled task status: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Status marker semantics. `stopping` and `killed` share the attention color:
|
||||
* both mean the work ended (or is ending) on request rather than on its own.
|
||||
*/
|
||||
function dotState(status: TaskView['status']): StateDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'stopping': return 'warning'
|
||||
case 'completed': return 'done'
|
||||
case 'killed': return 'warning'
|
||||
case 'failed': return 'error'
|
||||
/* v8 ignore next -- closed wire status union */
|
||||
default: return assertNever(status)
|
||||
}
|
||||
}
|
||||
|
||||
/** Human status word for the row and its accessible name. */
|
||||
function statusLabel(status: TaskView['status'], t: TranslateNS<typeof NS>): string {
|
||||
switch (status) {
|
||||
case 'running': return t('status.running')
|
||||
case 'stopping': return t('status.stopping')
|
||||
case 'completed': return t('status.completed')
|
||||
case 'killed': return t('status.killed')
|
||||
case 'failed': return t('status.failed')
|
||||
/* v8 ignore next -- closed wire status union */
|
||||
default: return assertNever(status)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elapsed time in at most two adjacent units. A background task that outlives
|
||||
* an hour is already exceptional, so hours is the widest unit — beyond that the
|
||||
* figure stays in hours rather than growing a day/month vocabulary no producer
|
||||
* currently reaches.
|
||||
*/
|
||||
function formatDuration(elapsedMs: number, t: TranslateNS<typeof NS>): string {
|
||||
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
|
||||
const seconds = total % 60
|
||||
const minutes = Math.floor(total / 60) % 60
|
||||
const hours = Math.floor(total / 3_600)
|
||||
if (hours > 0) return t('duration.hours', { hours, minutes })
|
||||
if (minutes > 0) return t('duration.minutes', { minutes, seconds })
|
||||
return t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Live rows first in start order, then settled rows newest-first. Two tasks
|
||||
* that settled in the same millisecond fall back to start order, so the sort
|
||||
* never depends on the host's map iteration.
|
||||
*/
|
||||
function ordered(tasks: readonly TaskView[]): TaskView[] {
|
||||
return [...tasks].sort((left, right) => {
|
||||
const liveLeft = isLive(left)
|
||||
if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1
|
||||
if (liveLeft) return left.startedAt - right.startedAt
|
||||
const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt)
|
||||
return finished !== 0 ? finished : left.startedAt - right.startedAt
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-header entry point for this session's background tasks. It renders
|
||||
* nothing at all until the session has at least one task, so an ordinary
|
||||
* conversation never grows a control for a capability it is not using.
|
||||
* @param props - runtime slot currency plus the namespace translator.
|
||||
* @returns the trigger and its popover list, or null when there is nothing to show.
|
||||
*/
|
||||
export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProps) {
|
||||
const tasks = useSessions(state => state.tasksBySession[sessionId]) ?? NO_TASKS
|
||||
const [open, setOpen] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const rows = useMemo(() => ordered(tasks), [tasks])
|
||||
const liveCount = useMemo(() => tasks.filter(isLive).length, [tasks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOutside = (event: PointerEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
// The clock only runs while an open list is showing something that moves.
|
||||
useEffect(() => {
|
||||
if (!open || liveCount === 0) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [open, liveCount])
|
||||
|
||||
// The last task disappearing removes this control; close first so focus does
|
||||
// not vanish from an unmounting node.
|
||||
useEffect(() => {
|
||||
if (tasks.length === 0 && open) setOpen(false)
|
||||
}, [tasks.length, open])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const countKey = liveCount > 0
|
||||
? (liveCount === 1 ? 'count.live.one' : 'count.live.other')
|
||||
: (tasks.length === 1 ? 'count.idle.one' : 'count.idle.other')
|
||||
const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : tasks.length })
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key !== 'Escape' || !open) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.root} onKeyDown={onKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-expanded={open}
|
||||
aria-label={countLabel}
|
||||
onClick={() => { setOpen(current => !current) }}
|
||||
>
|
||||
{liveCount > 0 ? <StateDot state="ongoing" className={css.triggerDot} /> : null}
|
||||
<span className={css.count}>{countLabel}</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open
|
||||
? (
|
||||
<ul className={css.menu} aria-label={t('list.aria')}>
|
||||
{rows.map((task) => {
|
||||
const live = isLive(task)
|
||||
const elapsed = live ? now - task.startedAt : (task.finishedAt ?? task.startedAt) - task.startedAt
|
||||
const duration = formatDuration(elapsed, t)
|
||||
const status = statusLabel(task.status, t)
|
||||
return (
|
||||
<li key={task.id} className={live ? css.row : `${css.row} ${css.rowSettled}`}>
|
||||
<StateDot state={dotState(task.status)} className={css.rowDot} />
|
||||
<span className={css.kind}>{task.kind}</span>
|
||||
<span className={css.label} title={task.label}>{task.label}</span>
|
||||
<span className={css.status}>{task.detail ?? status}</span>
|
||||
<span
|
||||
className={css.duration}
|
||||
title={t(live ? 'duration.title.live' : 'duration.title.done', { duration })}
|
||||
>
|
||||
{duration}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
packages/client/ui-task/src/client/index.ts
Normal file
40
packages/client/ui-task/src/client/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Background-task plugin, browser half: contributes one session-header action
|
||||
* that renders this session's `ctx.tasks` records. The data arrives entirely
|
||||
* through the `tasksBySession` list mirror, so the plugin issues no RPC and
|
||||
* holds no state of its own beyond popover visibility.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TaskListAction } from './TaskListAction.tsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { en, NS, zh, type TaskKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Background-task list copy. */
|
||||
'task': TaskKey
|
||||
}
|
||||
}
|
||||
|
||||
export type { TaskListActionProps } from './TaskListAction.tsx'
|
||||
|
||||
/** Required services for locale registration and header-slot contribution. */
|
||||
export const inject = ['sessions', 'slots', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries and the header action.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-task: dictionaries')
|
||||
ctx.slots.inject(
|
||||
'conversation.session.header.actions',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'task-list',
|
||||
// After the subagent catalog: session lineage reads before process work.
|
||||
order: 20,
|
||||
locale: NS,
|
||||
}, TaskListAction),
|
||||
)
|
||||
}
|
||||
45
packages/client/ui-task/src/client/locales.ts
Normal file
45
packages/client/ui-task/src/client/locales.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** `task` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'task'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'count.live.one': '{count} 个后台任务运行中',
|
||||
'count.live.other': '{count} 个后台任务运行中',
|
||||
'count.idle.one': '{count} 个后台任务',
|
||||
'count.idle.other': '{count} 个后台任务',
|
||||
'list.aria': '后台任务',
|
||||
'status.running': '运行中',
|
||||
'status.stopping': '正在停止',
|
||||
'status.completed': '已完成',
|
||||
'status.killed': '已取消',
|
||||
'status.failed': '已失败',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'duration.hours': '{hours}小时{minutes}分',
|
||||
'duration.title.live': '已运行 {duration}',
|
||||
'duration.title.done': '耗时 {duration}',
|
||||
} as const
|
||||
|
||||
/** English dictionary, key-identical to the Chinese source of truth. */
|
||||
export const en: Record<TaskKey, string> = {
|
||||
'count.live.one': '{count} background task running',
|
||||
'count.live.other': '{count} background tasks running',
|
||||
'count.idle.one': '{count} background task',
|
||||
'count.idle.other': '{count} background tasks',
|
||||
'list.aria': 'Background tasks',
|
||||
'status.running': 'running',
|
||||
'status.stopping': 'stopping',
|
||||
'status.completed': 'completed',
|
||||
'status.killed': 'cancelled',
|
||||
'status.failed': 'failed',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'duration.hours': '{hours}h {minutes}m',
|
||||
'duration.title.live': 'Running for {duration}',
|
||||
'duration.title.done': 'Took {duration}',
|
||||
}
|
||||
|
||||
/** Key domain of the `task` namespace (zh is the source of truth). */
|
||||
export type TaskKey = keyof typeof zh
|
||||
6
packages/client/ui-task/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-task/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-task/src/index.ts
Normal file
9
packages/client/ui-task/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Background-task list plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
export function apply(): void {}
|
||||
32
packages/client/ui-task/src/invariant.ts
Normal file
32
packages/client/ui-task/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-task`.
|
||||
* @module @deepseek-ai/dsh-client-ui-task/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-task'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-task-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package is a read-only projection of the
|
||||
* `tasksBySession` mirror onto one header slot entry. It emits no cordis
|
||||
* events, owns no cross-plugin mutable state, and its single slot registration
|
||||
* proves disposal through the HMR-safety spec.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
89
packages/client/ui-task/tests/browser-plugin.spec.ts
Normal file
89
packages/client/ui-task/tests/browser-plugin.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* ui-task plugin halves: the browser entry's dictionary and header-slot
|
||||
* registrations against the real SlotsService (with fiber teardown proving
|
||||
* removal — HMR safety), the inert node entry, and the invariant companion's
|
||||
* ownership reservation.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import * as TaskInvariant from '../src/invariant.ts'
|
||||
import { en, NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
/** Slot ledger reader: entry ids currently registered in the header list. */
|
||||
function headerEntryIds(ctx: Context): (string | undefined)[] {
|
||||
return ctx.slots
|
||||
.entries('conversation.session.header.actions')
|
||||
.map(entry => entry.options.id)
|
||||
}
|
||||
|
||||
/** Boot the browser half over a real slot tree that declares the header list. */
|
||||
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('sessions', {})
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('ui-task browser half', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['sessions', 'slots', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
|
||||
const { ctx, fiber } = await bench()
|
||||
expect(headerEntryIds(ctx)).toContain('task-list')
|
||||
await fiber.dispose()
|
||||
expect(headerEntryIds(ctx)).not.toContain('task-list')
|
||||
})
|
||||
|
||||
it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
|
||||
const { ctx, fiber } = await bench()
|
||||
const translate = ctx.locale.bind(NS)
|
||||
expect(translate('list.aria')).toBe(zh['list.aria'])
|
||||
ctx.locale.setLocale('en')
|
||||
expect(translate('list.aria')).toBe(en['list.aria'])
|
||||
|
||||
// Withdrawn dictionaries leave the key unresolved rather than translated.
|
||||
await fiber.dispose()
|
||||
expect(translate('list.aria')).not.toBe(en['list.aria'])
|
||||
})
|
||||
|
||||
it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-task node half', () => {
|
||||
it('contributes no host behavior', () => {
|
||||
// The node half exists only so the plugin appears in the Loader tree.
|
||||
expect(applyNode).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-task invariant companion', () => {
|
||||
it('reserves package ownership under its declared companion name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = ctx.plugin(TaskInvariant)
|
||||
await fiber.await()
|
||||
expect(TaskInvariant.name).toBe('client-ui-task-invariant')
|
||||
expect(TaskInvariant.inject).toEqual(['invariants'])
|
||||
// Emitting an unrelated event proves the companion installed no audit.
|
||||
expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
239
packages/client/ui-task/tests/task-list-action.spec.tsx
Normal file
239
packages/client/ui-task/tests/task-list-action.spec.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// Live rows render `now - startedAt`, so every assertion needs a pinned clock.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(START)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const SESSION = 'session' as SessionId
|
||||
const START = 1_700_000_000_000
|
||||
const t: TaskListActionProps['t'] = makeTranslate(zh)
|
||||
|
||||
function task(over: Partial<TaskView> = {}): TaskView {
|
||||
return {
|
||||
id: 'bash-1' as TaskView['id'],
|
||||
kind: 'bash',
|
||||
label: 'pnpm run build',
|
||||
status: 'running',
|
||||
startedAt: START,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function props(tasks: readonly TaskView[] | undefined): TaskListActionProps {
|
||||
const state = {
|
||||
ids: [SESSION],
|
||||
byId: {},
|
||||
current: SESSION,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks },
|
||||
currentAddress: undefined,
|
||||
} satisfies SessionListState
|
||||
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
||||
return select(state)
|
||||
}
|
||||
return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows in render order as `[kind, label, status, duration]`. Adjacent spans
|
||||
* carry no whitespace between them, so the cells are read one element at a
|
||||
* time rather than split out of a flattened string.
|
||||
*/
|
||||
function rowCells(): string[][] {
|
||||
return within(screen.getByRole('list', { name: zh['list.aria'] }))
|
||||
.getAllByRole('listitem')
|
||||
.map(row => [...row.children]
|
||||
.map(cell => cell.textContent ?? '')
|
||||
.filter(text => text !== ''))
|
||||
}
|
||||
|
||||
describe('TaskListAction visibility', () => {
|
||||
it('renders nothing while the session has no tasks', () => {
|
||||
const { container } = render(<TaskListAction {...props(undefined)} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('counts only live tasks, and falls back to the total when none are live', () => {
|
||||
const { rerender } = render(<TaskListAction {...props([task(), task({ id: 'bash-2' as TaskView['id'] })])} />)
|
||||
expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
|
||||
|
||||
rerender(<TaskListAction {...props([task({ status: 'completed', finishedAt: START + 3_000 })])} />)
|
||||
expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('closes and unmounts when the last task disappears while the list is open', () => {
|
||||
const { container, rerender } = render(<TaskListAction {...props([task()])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
|
||||
|
||||
rerender(<TaskListAction {...props([])} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction rows', () => {
|
||||
it('orders live tasks by start, then settled tasks newest-first', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-3' as TaskView['id'], label: 'old done', status: 'completed', startedAt: START, finishedAt: START + 1_000 }),
|
||||
task({ id: 'bash-4' as TaskView['id'], label: 'new done', status: 'failed', startedAt: START, finishedAt: START + 9_000 }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'later live', startedAt: START + 5_000 }),
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'earlier live', startedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells()).toEqual([
|
||||
['bash', 'earlier live', '运行中', '0秒'],
|
||||
['bash', 'later live', '运行中', '0秒'],
|
||||
['bash', 'new done', '已失败', '9秒'],
|
||||
['bash', 'old done', '已完成', '1秒'],
|
||||
])
|
||||
})
|
||||
|
||||
it('breaks a settled tie on start order so map iteration never decides it', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'second', status: 'completed', startedAt: START + 10, finishedAt: START + 100 }),
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'first', status: 'completed', startedAt: START, finishedAt: START + 100 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('prefers the producer detail over the generic status word', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: START + 2_000 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells()[0]).toContain('signal: SIGTERM')
|
||||
})
|
||||
|
||||
it('renders every status word, including the stopping transition', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'a', status: 'running' }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'b', status: 'stopping' }),
|
||||
task({ id: 'bash-3' as TaskView['id'], label: 'c', status: 'completed', finishedAt: START }),
|
||||
task({ id: 'bash-4' as TaskView['id'], label: 'd', status: 'killed', finishedAt: START }),
|
||||
task({ id: 'bash-5' as TaskView['id'], label: 'e', status: 'failed', finishedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
const words = rowCells().map(cells => cells[2])
|
||||
expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction duration', () => {
|
||||
it('advances a live row once per second and freezes a settled one', () => {
|
||||
vi.setSystemTime(START + 1_000)
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'live' }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'done', status: 'completed', finishedAt: START + 4_000 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells()[0]).toContain('1秒')
|
||||
expect(rowCells()[1]).toContain('4秒')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(rowCells()[0]).toContain('3秒')
|
||||
expect(rowCells()[1]).toContain('4秒')
|
||||
})
|
||||
|
||||
it('widens to minutes and then hours, and never shows a negative figure', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'm', status: 'completed', finishedAt: START + 125_000 }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'h', status: 'completed', finishedAt: START + 7_380_000 }),
|
||||
// A clock that moved backwards must not render a negative duration.
|
||||
task({ id: 'bash-3' as TaskView['id'], label: 'skew', status: 'completed', startedAt: START + 5_000, finishedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒'])
|
||||
})
|
||||
|
||||
it('runs no clock while the list is closed', () => {
|
||||
const interval = vi.spyOn(globalThis, 'setInterval')
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
expect(interval).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(interval).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs no clock for an open list holding only settled tasks', () => {
|
||||
const interval = vi.spyOn(globalThis, 'setInterval')
|
||||
render(<TaskListAction {...props([task({ status: 'completed', finishedAt: START })])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(interval).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction dismissal', () => {
|
||||
it('closes on Escape and returns focus to the trigger', () => {
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('ignores other keys and a closed-list Escape', () => {
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('closes on an outside pointer press but not on one inside', () => {
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] }))
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction wire tolerance', () => {
|
||||
it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => {
|
||||
// `finishedAt` is optional on the wire; the Host always sets it, so this
|
||||
// covers a producer or carrier that ever stops doing so.
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'no finish', status: 'completed' }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'finished', status: 'completed', startedAt: START - 1_000, finishedAt: START + 2_000 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([
|
||||
['finished', '3秒'],
|
||||
['no finish', '0秒'],
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to start order when neither settled task carries a finish time', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'later', status: 'failed', startedAt: START + 1_000 }),
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'earlier', status: 'failed', startedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier'])
|
||||
})
|
||||
})
|
||||
33
packages/client/ui-task/tsconfig.json
Normal file
33
packages/client/ui-task/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-task/tsdown.config.ts
Normal file
3
packages/client/ui-task/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-task', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -101,7 +101,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
|
||||
@@ -30,7 +30,7 @@ function listStore() {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('FileMutationRow diff card', () => {
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -309,13 +309,13 @@ describe('DetailsPanel diff Output section', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('ReadRow keyed toolview', () => {
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -255,13 +255,13 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -346,7 +346,7 @@ describe('BashRow terminal card', () => {
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -446,13 +446,13 @@ describe('DetailsPanel Output section', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
@@ -643,7 +643,7 @@ describe('DetailsPanel Output section', () => {
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
}))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('DetailsPanel web Output section', () => {
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -122,7 +122,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
|
||||
@@ -29,7 +29,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial<Sessi
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
|
||||
@@ -1030,6 +1030,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void',
|
||||
jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract onTasksChanged(listener: TasksChangedListener): () => void',
|
||||
jsDoc: '/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, the stopping transition, settlement, and owner-disposal\n * removal — so an observer re-reads rather than accumulating deltas.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract attachSurface(name: string): () => void',
|
||||
jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */',
|
||||
@@ -2915,6 +2919,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TaskRead',
|
||||
declaration: 'export interface TaskRead {\n text: string;\n snapshot: TaskSnapshot;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TasksChangedListener',
|
||||
declaration: 'export type TasksChangedListener = (owner: Agent | undefined) => void;',
|
||||
},
|
||||
{
|
||||
name: 'TaskSnapshot',
|
||||
declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}',
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,10 @@ import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks'
|
||||
import type {
|
||||
TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus,
|
||||
TasksChangedListener,
|
||||
} from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
|
||||
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
|
||||
@@ -59,6 +62,7 @@ export class LocalTaskService extends TaskService {
|
||||
private counters = new Map<string, number>()
|
||||
private surfaces = new Set<symbol>()
|
||||
private listeners = new Set<TaskDoneListener>()
|
||||
private changeListeners = new Set<TasksChangedListener>()
|
||||
private listenersClosed = false
|
||||
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
|
||||
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
||||
@@ -119,6 +123,9 @@ export class LocalTaskService extends TaskService {
|
||||
this.settle(task, { status: 'failed', detail: String(error) })
|
||||
},
|
||||
)
|
||||
// Registration is complete and cannot fail from here, so the visible set
|
||||
// has genuinely changed.
|
||||
this.notifyChanged(task.owner)
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -156,6 +163,7 @@ export class LocalTaskService extends TaskService {
|
||||
task.cancel(reason)
|
||||
task.status = 'stopping'
|
||||
task.reported = true
|
||||
this.notifyChanged(task.owner)
|
||||
return 'requested'
|
||||
}
|
||||
|
||||
@@ -217,6 +225,14 @@ export class LocalTaskService extends TaskService {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
onTasksChanged(listener: TasksChangedListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.changeListeners.add(listener)
|
||||
return () => this.changeListeners.delete(listener)
|
||||
}, 'tasks.onTasksChanged()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
attachSurface(name: string): () => void {
|
||||
// One token per call keeps duplicate labels independently disposable.
|
||||
const token = Symbol(name)
|
||||
@@ -262,6 +278,20 @@ export class LocalTaskService extends TaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce that one owner's visible set changed. Each listener is contained
|
||||
* so an observer cannot break a lifecycle commit that already happened.
|
||||
*/
|
||||
private notifyChanged(owner: Agent | undefined): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
try {
|
||||
listener(owner)
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`tasks: onTasksChanged listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the first terminal outcome, notify contained listeners, and release
|
||||
* waiters. First-wins preserves a teardown force-failure against late producer
|
||||
@@ -291,6 +321,7 @@ export class LocalTaskService extends TaskService {
|
||||
task.waitResolvers.clear()
|
||||
for (const resolveWait of waitResolvers) resolveWait()
|
||||
task.markSettled()
|
||||
this.notifyChanged(task.owner)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,6 +354,9 @@ export class LocalTaskService extends TaskService {
|
||||
this.cancelForTeardown(owned, 'owner disposed')
|
||||
await Promise.all(owned.map(task => task.settled))
|
||||
for (const task of owned) this.store.delete(task.id)
|
||||
// Removal is the one visible-set change no per-task record carries, so it
|
||||
// must be announced here or an observer keeps the dropped rows forever.
|
||||
if (owned.length > 0) this.notifyChanged(owner)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,6 +370,11 @@ export class LocalTaskService extends TaskService {
|
||||
this.cancelForTeardown(all, 'tasks service disposed')
|
||||
await Promise.all(all.map(task => task.settled))
|
||||
this.store.clear()
|
||||
// No change notification here: every `onTasksChanged` registration is an
|
||||
// effect on this service's own fiber, so the listeners are already gone by
|
||||
// the time service teardown reaches this line. An observer learns the
|
||||
// registry left through its own disposal, not through a final empty set.
|
||||
this.changeListeners.clear()
|
||||
// Detach cross-fiber owner effects after the shared store is quiescent.
|
||||
const ownerCleanups = [...this.ownerCleanups.values()]
|
||||
this.ownerCleanups.clear()
|
||||
|
||||
@@ -760,3 +760,96 @@ describe('LocalTaskService disposal', () => {
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalTaskService.onTasksChanged', () => {
|
||||
it('fires after registration, the stopping transition, and settlement', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'alice')
|
||||
ctx.agents.register(owner)
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
|
||||
const p = producer({ owner })
|
||||
const id = ctx.tasks.start(p.spec)
|
||||
// Registration is announced only once the record is readable.
|
||||
expect(seen).toEqual(['alice'])
|
||||
expect(ctx.tasks.list(owner)).toHaveLength(1)
|
||||
|
||||
expect(ctx.tasks.kill(id, owner)).toBe('requested')
|
||||
expect(seen).toEqual(['alice', 'alice'])
|
||||
expect(ctx.tasks.get(id, owner).status).toBe('stopping')
|
||||
|
||||
p.settle({ status: 'killed' })
|
||||
await tick()
|
||||
expect(seen).toEqual(['alice', 'alice', 'alice'])
|
||||
expect(ctx.tasks.get(id, owner).status).toBe('killed')
|
||||
await disposeAgentScope(owner)
|
||||
})
|
||||
|
||||
it('reports an unowned change as undefined, since every caller can see it', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('announces the owner-disposal removal, and stays silent when that owner had none', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'alice')
|
||||
const bystander = stubAgent(ctx, 'bob')
|
||||
ctx.agents.register(owner)
|
||||
ctx.agents.register(bystander)
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(seen).toEqual(['alice'])
|
||||
|
||||
// Disposing an owner with no records changes no visible set.
|
||||
await disposeAgentScope(bystander)
|
||||
expect(seen).toEqual(['alice'])
|
||||
|
||||
await disposeAgentScope(owner)
|
||||
expect(seen).toEqual(['alice', 'alice'])
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('contains a throwing listener so the lifecycle commit still stands', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: (string | undefined)[] = []
|
||||
ctx.tasks.onTasksChanged(() => { throw new Error('observer boom') })
|
||||
ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id))
|
||||
|
||||
const id = ctx.tasks.start(producer().spec)
|
||||
expect(id).toBe('bash-1')
|
||||
expect(seen).toEqual([undefined])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTasksChanged listener threw'))
|
||||
})
|
||||
|
||||
it('unregisters through its disposer and with its fiber (HMR safety)', async () => {
|
||||
const ctx = await harness()
|
||||
const seen: number[] = []
|
||||
const detach = ctx.tasks.onTasksChanged(() => void seen.push(1))
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.tasks.onTasksChanged(() => void seen.push(2))
|
||||
}, { inject: ['tasks'] }))
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([1, 2])
|
||||
|
||||
detach()
|
||||
detach() // second call of the same disposer is a no-op
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([1, 2, 2])
|
||||
|
||||
await fiber.dispose()
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(seen).toEqual([1, 2, 2])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/tasks/tasks/README.md
|
||||
README.md: 2f822bad139020f0ebae0165aa4e8893853f635d
|
||||
README.zh.md: fdc619fbb46267b2ae550c85cb14fcf8a916f638
|
||||
README.md: e7431cca0956429788274b0d44a249dd1e374156
|
||||
README.zh.md: e8c1162f66471eee3c04111c8617dd84295dc6cf
|
||||
|
||||
@@ -12,6 +12,7 @@ The background task registry seam (`ctx.tasks`). The abstract `TaskService` and
|
||||
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
|
||||
- `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter.
|
||||
- `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited.
|
||||
- `onTasksChanged(listener)` observes visible-set changes — registration, the stopping transition, settlement, and owner-disposal removal — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported.
|
||||
- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached.
|
||||
|
||||
Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。
|
||||
- `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。
|
||||
- `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。
|
||||
- `onTasksChanged(listener)` 观察可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。
|
||||
- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。
|
||||
|
||||
有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./brand": {
|
||||
"types": "./lib/types/brand.d.ts",
|
||||
"default": "./lib/types/brand.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
28
packages/tasks/tasks/src/brand.ts
Normal file
28
packages/tasks/tasks/src/brand.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* dsh-tasks' owned branded id, carried across the registry, the model-facing
|
||||
* control surface, and the client wire.
|
||||
*
|
||||
* It lives in its own leaf because the package root and `./types` both reach
|
||||
* `dsh-agent` through the owner and listener signatures, which a Client program
|
||||
* cannot resolve even as a type. A browser-safe consumer imports the id here;
|
||||
* `Branded<B>` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tasks/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Identifies a background task. The registry generates `<kind>-N`; predictable
|
||||
* ids rely on owner authorization rather than secrecy.
|
||||
*/
|
||||
export type TaskId = Branded<'TaskId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link TaskId}.
|
||||
* @param id - the raw task-id string (the registry generates `<kind>-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function TaskId(id: string): TaskId {
|
||||
return id as TaskId
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts'
|
||||
import type {
|
||||
TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener,
|
||||
} from './types.ts'
|
||||
|
||||
export { TaskId } from './types.ts'
|
||||
export type {
|
||||
@@ -21,6 +23,7 @@ export type {
|
||||
TaskSnapshot,
|
||||
TaskStart,
|
||||
TaskStatus,
|
||||
TasksChangedListener,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -129,6 +132,22 @@ export abstract class TaskService extends Service {
|
||||
*/
|
||||
abstract onTaskDone(listener: TaskDoneListener): () => void
|
||||
|
||||
/**
|
||||
* Register an effect-scoped observer of visible-set changes. It fires after
|
||||
* every commit that changes what {@link list} returns for that owner —
|
||||
* registration, the stopping transition, settlement, and owner-disposal
|
||||
* removal — so an observer re-reads rather than accumulating deltas.
|
||||
*
|
||||
* This is not a superset of {@link onTaskDone}: that one delivers the terminal
|
||||
* record under first-wins semantics a control surface couples to notice
|
||||
* delivery, while this one carries no delivery meaning and marks nothing
|
||||
* reported. Listeners are contained and never awaited.
|
||||
* @param listener - receives the owner whose visible set changed, or
|
||||
* `undefined` when an unowned task changed and every caller's set did.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
abstract onTasksChanged(listener: TasksChangedListener): () => void
|
||||
|
||||
/**
|
||||
* Attach an effect-scoped surface that can read and stop tasks. {@link start}
|
||||
* refuses work while none is attached.
|
||||
|
||||
@@ -4,24 +4,11 @@
|
||||
* @module @deepseek-ai/dsh-tasks/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TaskId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* Identifies a background task. The registry generates `<kind>-N`; predictable
|
||||
* ids rely on owner authorization rather than secrecy.
|
||||
*/
|
||||
export type TaskId = Branded<'TaskId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link TaskId}.
|
||||
* @param id - the raw task-id string (the registry generates `<kind>-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function TaskId(id: string): TaskId {
|
||||
return id as TaskId
|
||||
}
|
||||
export { TaskId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* Task lifecycle: `running`, optionally `stopping`, then exactly one terminal
|
||||
@@ -157,3 +144,14 @@ export type TaskDoneListener = (
|
||||
snapshot: TaskSnapshot,
|
||||
owner: Agent | undefined,
|
||||
) => void | PromiseLike<void>
|
||||
|
||||
/**
|
||||
* Observation callback for a change to what one owner's {@link TaskService.list}
|
||||
* would return. It is owner-granular rather than task-granular because the
|
||||
* change may be a removal, which no per-task record can express, and because
|
||||
* its consumers re-read the whole visible set anyway.
|
||||
*
|
||||
* An `undefined` owner means an unowned task changed, so every caller's visible
|
||||
* set changed with it.
|
||||
*/
|
||||
export type TasksChangedListener = (owner: Agent | undefined) => void
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { TaskId, TaskService } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskDoneListener, TaskRead, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
||||
import type {
|
||||
TaskDoneListener, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener,
|
||||
} from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
/**
|
||||
* Minimal concrete registry: one canned record. The seam owns the contract
|
||||
@@ -50,6 +52,10 @@ class StubTaskService extends TaskService {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
onTasksChanged(_listener: TasksChangedListener): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
attachSurface(_name: string): () => void {
|
||||
return () => {}
|
||||
}
|
||||
@@ -70,6 +76,8 @@ describe('TaskService seam', () => {
|
||||
await expect(ctx.tasks.wait(id, 5)).resolves.toMatchObject({ id })
|
||||
const detachListener = ctx.tasks.onTaskDone(() => {})
|
||||
detachListener()
|
||||
const detachChanges = ctx.tasks.onTasksChanged(() => {})
|
||||
detachChanges()
|
||||
detachSurface()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user