Merge branch 'worktree/jobs-api-review-3052' into worktree/jobs-api-review-3127
# Conflicts: # packages/client/ui-jobs/src/client/JobListAction.tsx # packages/shell/bash-local/tests/executor.spec.ts
This commit is contained in:
commit
c4bc8b847a
1836 files changed
+37648
-15031
No files matched your search
@@ -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/api/session-controller/README.md
|
||||
README.md: ed06841cbc3025af385dc2014fa09f30b0e1e8b9
|
||||
README.zh.md: 2a461d5af41348c6bf5066d073ede17649ac5f6d
|
||||
README.md: ba0cc6dc5fdb8ca0355cb63791fb4c100fde4be0
|
||||
README.zh.md: 87dec540a83794f3f496dae065ff58cc12d20104
|
||||
@@ -29,7 +29,11 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
|
||||
|
||||
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
|
||||
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application.
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs.
|
||||
|
||||
Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
|
||||
|
||||
Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies.
|
||||
|
||||
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes.
|
||||
|
||||
@@ -46,6 +50,8 @@ Fork copies history through the selected completed turn, including its `turn/end
|
||||
|
||||
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
|
||||
|
||||
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` still publishes its single page directly.
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client references
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ kind: "package-reference"
|
||||
|
||||
Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。
|
||||
|
||||
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
|
||||
|
||||
显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。
|
||||
|
||||
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含本地引用来源的正计数;Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。
|
||||
|
||||
@@ -46,6 +50,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
|
||||
|
||||
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
|
||||
|
||||
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client 引用
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-session-controller",
|
||||
"description": "Session Remote commands, cold reads, and live control transport",
|
||||
"version": "0.1.6-alpha.1",
|
||||
"version": "0.1.6-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -127,6 +127,8 @@ export interface ISession {
|
||||
* turn-jump loader. Repeated calls while a jump is paging lower its shared
|
||||
* target and return the in-flight completion; `snapshot.loadingOlder` is
|
||||
* the busy signal for the whole jump.
|
||||
* Older pages publish together at completion, including successful pages
|
||||
* before a later failure; live events remain independently visible.
|
||||
* @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
|
||||
* @returns completion once covered, exhausted, superseded, or failed soft.
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface SessionListEntry {
|
||||
title?: string
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
/** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
|
||||
import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type {
|
||||
SessionControlBaseline,
|
||||
@@ -223,6 +224,7 @@ export class SessionManager {
|
||||
/**
|
||||
* Lazy build: return the existing instance or construct one (no auto-open —
|
||||
* the reference allocator opens history after binding the scope).
|
||||
* New instances reconcile retained metadata before returning.
|
||||
* @param sessionId - the session to get.
|
||||
* @returns the resident instance.
|
||||
*/
|
||||
@@ -246,6 +248,9 @@ export class SessionManager {
|
||||
// durable history, even though child rows do not carry `blank`.
|
||||
session.handleBlank(false)
|
||||
session.handleRunning(child.activity === 'running')
|
||||
} else {
|
||||
// Retained metadata may have notified before this Session existed.
|
||||
session.handleBlank(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,9 +281,14 @@ export class SessionManager {
|
||||
let store = this.projectionStores.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = new ProjectionValueStore()
|
||||
// List rows project off store keys (title); any-key changes re-enter
|
||||
// the manager's own batched rebuild channel.
|
||||
store.subscribeAny(() => { this.notifier.markDirty() })
|
||||
const projections = store
|
||||
store.subscribeAny(() => {
|
||||
// Newer history or control metadata corrects a resident Session's stale list hint.
|
||||
if (projections.values().sessionListMetadata?.blank === false) {
|
||||
this.sessions.get(sessionId)?.handleBlank(false)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.projectionStores.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
@@ -405,7 +415,7 @@ export class SessionManager {
|
||||
this.summaries = mutations.reduce(applyMutation, baseline)
|
||||
this.listState = 'idle'
|
||||
this.listPhase = 'ready'
|
||||
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
// Sessions reconcile list blank hints with their current metadata projection.
|
||||
for (const s of this.summaries) {
|
||||
const session = this.sessions.get(s.sessionId)
|
||||
if (session === undefined) continue
|
||||
@@ -575,6 +585,15 @@ export class SessionManager {
|
||||
return this.listSnapshotCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cached projection values for a Session that may exist only in a loaded subagent catalog.
|
||||
* @param sessionId - Session whose control or history baseline supplied projections.
|
||||
* @returns current values, or undefined before any projection store exists.
|
||||
*/
|
||||
projectionValues(sessionId: SessionId): Readonly<Partial<SessionProjectionMap>> | undefined {
|
||||
return this.projectionStores.get(sessionId)?.values()
|
||||
}
|
||||
|
||||
// ---- Live control and Host-event sinks ----
|
||||
|
||||
/**
|
||||
@@ -785,8 +804,12 @@ export class SessionManager {
|
||||
const projectionStore = this.projectionStores.get(summary.sessionId)
|
||||
const title = projectionStore?.get('title')
|
||||
const projectionValues = projectionStore?.values()
|
||||
const metadata = projectionValues?.sessionListMetadata
|
||||
return {
|
||||
...summary,
|
||||
// Cached list hints can precede a history opening or control update.
|
||||
blank: summary.blank && metadata?.blank !== false,
|
||||
updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
|
||||
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ export interface SessionSummary {
|
||||
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
|
||||
readonly retainedBy: SessionRetainInfo['retainedBy']
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
* store carries every row, while the Workspace browser shows only the
|
||||
* selected blank entry.
|
||||
* Blankness from the Host summary reconciled with `sessionListMetadata`.
|
||||
* New Session reuses a blank one targeting the same workspace. Filtering
|
||||
* stays with the consumer: the store carries every row, while the Workspace
|
||||
* browser shows only the selected blank entry.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
@@ -648,16 +648,25 @@ export class ClientSessions implements ISessions {
|
||||
for (const child of catalog.entries) {
|
||||
if (child.kind !== 'child') continue
|
||||
const childId = child.id
|
||||
const displayTitle = child.label ?? childId
|
||||
const summary = byId[childId]
|
||||
const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId)
|
||||
const projectedTitle = projectionValues?.title
|
||||
const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined
|
||||
const displayTitle = title ?? child.label ?? childId
|
||||
if (summary === undefined) {
|
||||
byId[childId] = {
|
||||
id: childId, displayTitle, parentId: parentId as SessionId,
|
||||
origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0,
|
||||
retainedBy: this.retentionSnapshot(childId).retainedBy,
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
...(title === undefined ? {} : { title }),
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) {
|
||||
byId[childId] = {
|
||||
...summary,
|
||||
displayTitle,
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle) {
|
||||
byId[childId] = { ...summary, displayTitle }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ export const PAGE_MESSAGES = 50
|
||||
/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
|
||||
export const JUMP_PAGE_MESSAGES = 200
|
||||
|
||||
interface PendingHistory {
|
||||
beforeSeq: SessionLogOffset
|
||||
hasMore: boolean
|
||||
readonly pages: (readonly SessionEventLikeEntry[])[]
|
||||
}
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/** Catalog-discovered address selecting non-activating subagent transport. */
|
||||
@@ -97,6 +103,7 @@ export class Session implements SessionFace {
|
||||
private jumpTargetSeq: SessionSeq | null = null
|
||||
/** The running jump loop's completion, shared by retargeting callers. */
|
||||
private jumpPromise: Promise<void> | null = null
|
||||
private pendingHistory: PendingHistory | null = null
|
||||
private readonly stopObservingInbox: () => void
|
||||
private readonly assistantStream = new ClientAssistantStream()
|
||||
private running = false
|
||||
@@ -419,6 +426,14 @@ export class Session implements SessionFace {
|
||||
// target behind — only the loop's finally clears that field, and no
|
||||
// loop starts here.
|
||||
if (this.loadingOlder) return Promise.resolve()
|
||||
const events = this.events
|
||||
if (events === undefined) return Promise.resolve()
|
||||
const pending: PendingHistory = {
|
||||
beforeSeq: this.baseSeq,
|
||||
hasMore: this.hasMore,
|
||||
pages: [],
|
||||
}
|
||||
this.pendingHistory = pending
|
||||
this.jumpTargetSeq = seq
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
@@ -428,15 +443,13 @@ export class Session implements SessionFace {
|
||||
const generation = this.openGeneration
|
||||
this.jumpPromise = (async () => {
|
||||
try {
|
||||
while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
|
||||
while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
|
||||
if (generation !== this.openGeneration) return
|
||||
const events = this.events
|
||||
if (events === undefined) return
|
||||
const before = this.baseSeq
|
||||
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES })
|
||||
const before = pending.beforeSeq
|
||||
await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES })
|
||||
// No-progress guard: an empty or dropped page that still claims more
|
||||
// history must end the loop, not spin it.
|
||||
if (this.baseSeq >= before) return
|
||||
if (pending.beforeSeq >= before) return
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isRemoteFailure(error)) {
|
||||
@@ -445,7 +458,11 @@ export class Session implements SessionFace {
|
||||
} finally {
|
||||
this.jumpTargetSeq = null
|
||||
this.jumpPromise = null
|
||||
this.pendingHistory = null
|
||||
this.loadingOlder = false
|
||||
if (generation === this.openGeneration && pending.pages.length > 0) {
|
||||
this.prependWindow(pending.pages.reverse().flat(), pending.hasMore)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
@@ -535,13 +552,12 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank-bit relay from the authoritative summary source (`session.list` and
|
||||
* `api-session/added`). Monotone: once any signal (local first send,
|
||||
* running flip, an earlier summary) cleared it, a stale true never
|
||||
* re-blanks.
|
||||
* @param blank - the summary's derived empty-log bit.
|
||||
* Relay list blankness without overriding a started conversation established
|
||||
* by the current projection, a local prompt, or running state.
|
||||
* @param blank - whether the list or projection reports an unstarted conversation.
|
||||
*/
|
||||
handleBlank(blank: boolean): void {
|
||||
blank = blank && this.projections.values().sessionListMetadata?.blank !== false
|
||||
if (blank === this.blankBit) return
|
||||
if (blank && (this.promptAttempted || this.running)) return
|
||||
this.blankBit = blank
|
||||
@@ -648,6 +664,11 @@ export class Session implements SessionFace {
|
||||
const visible = this.assistantStream.replace(entries, assistantStream)
|
||||
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
|
||||
this.hasMore = hasMore
|
||||
if (this.pendingHistory !== null) {
|
||||
this.pendingHistory.beforeSeq = this.baseSeq
|
||||
this.pendingHistory.hasMore = hasMore
|
||||
this.pendingHistory.pages.length = 0
|
||||
}
|
||||
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(visible, hasMore)
|
||||
@@ -684,6 +705,13 @@ export class Session implements SessionFace {
|
||||
|
||||
/** Prepend one stream-validated history page. */
|
||||
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
|
||||
if (this.pendingHistory !== null) {
|
||||
const pending = this.pendingHistory
|
||||
pending.beforeSeq = entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq)
|
||||
pending.hasMore = hasMore
|
||||
pending.pages.push(entries)
|
||||
return
|
||||
}
|
||||
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
|
||||
this.hasMore = hasMore
|
||||
this.eventSource.prepend(entries, hasMore)
|
||||
|
||||
@@ -521,6 +521,9 @@ export class SessionCommandController {
|
||||
|
||||
private rejectCreation(sessionId: SessionId, error: unknown): never {
|
||||
if (remoteErrorOf(error) !== undefined) throw error
|
||||
if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
|
||||
throw new RemoteError('session/writer-held', error.message, { sessionId })
|
||||
}
|
||||
if (error instanceof ApiSessionPresetConflict) {
|
||||
throw new RemoteError('agent-preset/conflict', error.message, {
|
||||
sessionId: error.sessionId,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
|
||||
|
||||
import { hostname } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -90,6 +92,7 @@ export class SessionController extends TypertRemoteService {
|
||||
'agents',
|
||||
'attachments',
|
||||
'fileUploads',
|
||||
'fs',
|
||||
'llm',
|
||||
'sessions',
|
||||
'sessionProjections',
|
||||
@@ -135,7 +138,7 @@ export class SessionController extends TypertRemoteService {
|
||||
}, 'session-controller.promotions')
|
||||
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
|
||||
this.listState = new ApiSessionList(ctx)
|
||||
this.openPath = internals.openPath ?? openNativePath
|
||||
this.openPath = internals.openPath ?? openNativeAssociatedPath
|
||||
this.revealPath = internals.revealPath ?? revealNativePath
|
||||
this.canOpenPath = internals.canOpenPath
|
||||
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
|
||||
@@ -284,11 +287,11 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one path prepared by a Session-aware caller on the Host desktop.
|
||||
* Verify one path through the composed filesystem and open it on the Host desktop.
|
||||
* @param request - path after best-effort Session workspace resolution.
|
||||
* @param signal - caller lifetime; abort terminates the native command.
|
||||
* @returns confirmation after the native opener accepts the path.
|
||||
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
|
||||
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
|
||||
*/
|
||||
@Remote('openWorkspacePath')
|
||||
async openWorkspacePath(
|
||||
@@ -304,6 +307,13 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const hostPath = resolve(request.path)
|
||||
const { fs } = this.ctx
|
||||
const mapped = fs.processPathFromHostPath(hostPath)
|
||||
if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) {
|
||||
throw new Error('Path has no verified Host path')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (request.action === 'reveal') await this.revealPath(request.path, signal)
|
||||
else await this.openPath(request.path, signal)
|
||||
return { opened: true }
|
||||
|
||||
@@ -119,6 +119,10 @@ describe('Session creation failures', () => {
|
||||
error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'),
|
||||
code: 'session/conflict',
|
||||
},
|
||||
{
|
||||
error: Object.assign(new Error('writer already held'), { name: 'SessionAlreadyOwnedError' }),
|
||||
code: 'session/writer-held',
|
||||
},
|
||||
{
|
||||
error: new Error('factory unavailable'),
|
||||
code: 'gateway/internal',
|
||||
|
||||
@@ -57,6 +57,28 @@ describe('SessionManager instances', () => {
|
||||
expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
|
||||
})
|
||||
|
||||
it('initializes blankness from metadata published before the Session and list exist', async ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
const published = Promise.withResolvers<undefined>()
|
||||
const stopObserving = manager.subscribe(() => { published.resolve(undefined) })
|
||||
try {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1,
|
||||
key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S2,
|
||||
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 })
|
||||
// The initial notification must finish while neither Session is resident.
|
||||
await published.promise
|
||||
|
||||
expect(manager.getListSnapshot().items).toEqual([])
|
||||
expect(manager.get(S1).getSnapshot()).toMatchObject({ blank: false, running: false })
|
||||
expect(manager.get(S2).getSnapshot()).toMatchObject({ blank: true, running: false })
|
||||
expect(manager.get('fk-unobserved' as SessionId).getSnapshot().blank).toBe(true)
|
||||
} finally {
|
||||
stopObserving()
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('SessionManager query lifetime', () => {
|
||||
@@ -124,6 +146,28 @@ describe('SessionManager query lifetime', () => {
|
||||
})
|
||||
|
||||
describe('list lifecycle', () => {
|
||||
it('uses metadata received before the list without reblanking started rows', async ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
try {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1,
|
||||
key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 })
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S2,
|
||||
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 })
|
||||
remote.session.list.mockResolvedValue(ok({ items: [
|
||||
summary(S1, { blank: true }), summary(S2),
|
||||
] as never[] }))
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot().items.map(item => item.blank)).toEqual([false, false])
|
||||
expect(manager.get(S1).getSnapshot().blank).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().blank).toBe(false)
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1,
|
||||
key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 7 })
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 })
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('fills missing durable links without overwriting established rows and projects first-send engagement', async ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
remote.session.list.mockResolvedValueOnce(ok({ items: [
|
||||
@@ -145,6 +189,39 @@ describe('list lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an opened conversation out of blank-session reuse after a stale list refresh', async ({ mock, remote }) => {
|
||||
const stale = {
|
||||
...summary(S1, { blank: true, cwd: '/workspace' }),
|
||||
projections: { asOfSeq: 2, values: {
|
||||
sessionListMetadata: { blank: true, lastPromptAt: null },
|
||||
} },
|
||||
}
|
||||
remote.session.list.mockResolvedValue(ok({ items: [stale] as never[] }))
|
||||
const manager = makeManager(mock, remote)
|
||||
try {
|
||||
await manager.refreshList()
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().blank).toBe(true)
|
||||
|
||||
// History opening can be newer than the metadata-only list cache.
|
||||
session.projections.seed({ asOfSeq: SessionSeq(61), values: {
|
||||
sessionListMetadata: { blank: false, lastPromptAt: 1200 },
|
||||
} })
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 })
|
||||
expect(session.getSnapshot().blank).toBe(false)
|
||||
})
|
||||
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 })
|
||||
expect(session.getSnapshot().blank).toBe(false)
|
||||
manager.handleSessionAdded(stale)
|
||||
expect(session.getSnapshot().blank).toBe(false)
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('projects cold Session additions and an empty-cut control baseline before history exists', async ({ mock, remote }) => {
|
||||
const manager = makeManager(mock, remote)
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as nativeCommand from '@deepseek-ai/dsh-native-command'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import FsLocal from '@deepseek-ai/dsh-fs-local'
|
||||
import { onTestFinished } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createSessionTestController,
|
||||
@@ -12,6 +14,8 @@ async function context(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(FsLocal)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -74,9 +78,9 @@ describe('session/openWorkspacePath', () => {
|
||||
openPath,
|
||||
})
|
||||
|
||||
await remote.openWorkspacePath({ path: '/tmp/result.html' })
|
||||
await remote.openWorkspacePath({ path: '/workspace/result.html' })
|
||||
await remote.openWorkspacePath({ path: 'result.html' })
|
||||
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html'])
|
||||
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/workspace/result.html', 'result.html'])
|
||||
})
|
||||
|
||||
it('rejects empty paths before opening anything', async () => {
|
||||
@@ -170,3 +174,44 @@ it('uses the native reveal adapter without a test override and respects unsuppor
|
||||
expect(reveal).toHaveBeenCalledOnce()
|
||||
} finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
|
||||
it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before native %s', async (action) => {
|
||||
const ctx = await context()
|
||||
const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
onTestFinished(() => { mapping.mockRestore() })
|
||||
const openPath = vi.fn(async () => {})
|
||||
const revealPath = vi.fn(async () => {})
|
||||
const controller = createSessionTestController(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath,
|
||||
})
|
||||
await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
expect(revealPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a filesystem mapping that resolves to another process path', async () => {
|
||||
const ctx = await context()
|
||||
const mapping = vi.spyOn(ctx.fs, 'processPath').mockReturnValue('/different/report.html')
|
||||
onTestFinished(() => { mapping.mockRestore() })
|
||||
const openPath = vi.fn(async () => {})
|
||||
const controller = createSessionTestController(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath,
|
||||
})
|
||||
await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches default-app opening to the association adapter', async () => {
|
||||
const ctx = await context()
|
||||
const open = vi.spyOn(nativeCommand, 'openNativeAssociatedPath').mockResolvedValue(undefined)
|
||||
onTestFinished(() => { open.mockRestore() })
|
||||
const controller = createSessionTestController(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
await controller.openWorkspacePath({ path: '/report.html' }, signal)
|
||||
expect(open).toHaveBeenCalledWith('/report.html', signal)
|
||||
})
|
||||
@@ -58,6 +58,15 @@ describe('Session open', () => {
|
||||
expect(session.getSnapshot()).toMatchObject({ blank: false, running: true })
|
||||
}, COLD_BOOT_TIMEOUT_MS)
|
||||
|
||||
it('rejects a blank list hint when current metadata records a started conversation', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
session.projections.apply('sessionListMetadata', { blank: false, lastPromptAt: 1200 }, SessionSeq(8))
|
||||
|
||||
session.handleBlank(true)
|
||||
|
||||
expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: false, running: false })
|
||||
})
|
||||
|
||||
it('installs the tail page: cold → loading → open with window and nodes in place', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
const page = plainTurn(SessionSeq(10), 3, '问', '答')
|
||||
@@ -262,6 +271,88 @@ describe('paging', () => {
|
||||
expect(mock.log.requests()).toHaveLength(sent)
|
||||
})
|
||||
|
||||
it('publishes jump pages together while live events remain visible', async ({ mock, start }) => {
|
||||
const oldest = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const middle = plainTurn(SessionSeq(6), 1, 'middle', 'answer')
|
||||
const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newest, true)))
|
||||
await session.open()
|
||||
const secondRequested = Promise.withResolvers<undefined>()
|
||||
const lastPage = Promise.withResolvers<RemoteResult<SessionPage>>()
|
||||
mock.remote.session.page.mockImplementation(pageRule((request) => {
|
||||
if (request.beforeSeq === 12) return history(middle, true)
|
||||
secondRequested.resolve(undefined)
|
||||
return lastPage.promise
|
||||
}))
|
||||
const changes: string[] = []
|
||||
onTestFinished(session.eventSource.subscribe(() => { changes.push(session.eventSource.getSnapshot().change.kind) }))
|
||||
const jump = session.loadThrough(SessionSeq(0))
|
||||
await secondRequested.promise
|
||||
expect(changes).toEqual([])
|
||||
expect(eventSeqs(session)).toEqual(newest.map(event => event.seq))
|
||||
expect(session.getSnapshot().loadingOlder).toBe(true)
|
||||
|
||||
await pushEvent(mock, ev.turnStart(SessionSeq(18), 3))
|
||||
expect(changes).toEqual(['append'])
|
||||
expect(eventSeqs(session)).toEqual([...newest.map(event => event.seq), 18])
|
||||
const nearerJump = session.loadThrough(SessionSeq(6))
|
||||
expect(nearerJump).toBe(jump)
|
||||
|
||||
lastPage.resolve(history(oldest))
|
||||
await jump
|
||||
expect(changes).toEqual(['append', 'prepend'])
|
||||
expect(eventSeqs(session)).toEqual(Array.from({ length: 19 }, (_, index) => index))
|
||||
expect(session.eventSource.getSnapshot().change).toMatchObject({
|
||||
kind: 'prepend', entries: entries([...oldest, ...middle]),
|
||||
})
|
||||
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: false })
|
||||
})
|
||||
|
||||
it('publishes the successful jump prefix once when a later page fails', async ({ mock, start }) => {
|
||||
const middle = plainTurn(SessionSeq(6), 1, 'middle', 'answer')
|
||||
const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newest, true)))
|
||||
await session.open()
|
||||
const changed = vi.fn()
|
||||
onTestFinished(session.eventSource.subscribe(changed))
|
||||
mock.remote.session.page.mockImplementation(pageRule(request => request.beforeSeq === 12
|
||||
? history(middle, true)
|
||||
: err(new RemoteError('gateway/internal', 'page unavailable', {}))))
|
||||
|
||||
await session.loadThrough(SessionSeq(0))
|
||||
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(eventSeqs(session)).toEqual([...middle, ...newest].map(event => event.seq))
|
||||
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: true })
|
||||
})
|
||||
|
||||
it('discards buffered jump pages when the Session replaces its stream', async ({ mock, start }) => {
|
||||
const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newest, true)))
|
||||
await session.open()
|
||||
const secondRequested = Promise.withResolvers<undefined>()
|
||||
const lastPage = Promise.withResolvers<RemoteResult<SessionPage>>()
|
||||
mock.remote.session.page.mockImplementation(pageRule((request) => {
|
||||
if (request.beforeSeq === 12) return history(plainTurn(SessionSeq(6), 1, 'middle', 'answer'), true)
|
||||
secondRequested.resolve(undefined)
|
||||
return lastPage.promise
|
||||
}))
|
||||
const changes: string[] = []
|
||||
onTestFinished(session.eventSource.subscribe(() => { changes.push(session.eventSource.getSnapshot().change.kind) }))
|
||||
const jump = session.loadThrough(SessionSeq(0))
|
||||
await secondRequested.promise
|
||||
const replacement = session.resync()
|
||||
lastPage.resolve(history(plainTurn(SessionSeq(0), 0, 'old', 'answer')))
|
||||
await Promise.all([jump, replacement])
|
||||
|
||||
expect(changes).toEqual(['replace'])
|
||||
expect(eventSeqs(session)).toEqual(newest.map(event => event.seq))
|
||||
expect(session.getSnapshot().loadingOlder).toBe(false)
|
||||
})
|
||||
|
||||
it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async ({ mock, start }) => {
|
||||
const oldest = plainTurn(SessionSeq(0), 0, 'a', 'b')
|
||||
const middle = plainTurn(SessionSeq(6), 1, 'c', 'd')
|
||||
|
||||
@@ -703,6 +703,9 @@ describe('borrow-only bindings', () => {
|
||||
describe('catalog-addressed navigation', () => {
|
||||
it('opens an explicit catalog without retaining it and keeps one-shot labels optional', async ({ bench }) => {
|
||||
const b = bench()
|
||||
b.svc.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('one-shot'), key: 'title', value: 'Investigate startup', seq: 2,
|
||||
})
|
||||
b.mock.remote.subagents.list.mockResolvedValue(ok({
|
||||
entries: [
|
||||
{ kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive', hasChildren: false },
|
||||
@@ -714,13 +717,17 @@ describe('catalog-addressed navigation', () => {
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
b.svc.setSubagentCatalogOpen(sid('root'), false)
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe('one-shot')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('one-shot')]).toMatchObject({
|
||||
title: 'Investigate startup',
|
||||
displayTitle: 'Investigate startup',
|
||||
projectionValues: { title: 'Investigate startup' },
|
||||
})
|
||||
expect(b.svc.list.getSnapshot().byId[sid('missing')]).toBeUndefined()
|
||||
expect(b.svc.binding(sid('one-shot'))).toBeUndefined()
|
||||
expect(b.mock.remote.subagents.list).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses catalog labels for a listed addressed route', async ({ bench }) => {
|
||||
it('keeps projected titles in standard list rows for an addressed route', async ({ bench }) => {
|
||||
const b = bench()
|
||||
b.mock.remote.subagents.list.mockImplementation((payload) => {
|
||||
const parentSessionId = payload
|
||||
@@ -746,7 +753,10 @@ describe('catalog-addressed navigation', () => {
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 'root' },
|
||||
{ id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
|
||||
{
|
||||
id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent',
|
||||
projections: { title: 'Child session title' },
|
||||
},
|
||||
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
|
||||
])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
@@ -756,7 +766,11 @@ describe('catalog-addressed navigation', () => {
|
||||
}, { source: 'controllerOperation' })
|
||||
await _reference.ready
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
|
||||
title: 'Child session title',
|
||||
displayTitle: 'Child session title',
|
||||
projectionValues: { title: 'Child session title' },
|
||||
})
|
||||
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user