Merge remote-tracking branch 'origin/master' into turtle/image-cache-stale-0a243f674e12
# Conflicts: # packages/client/ui-primitives/README.i18n.yaml # packages/client/ui-primitives/README.md # packages/client/ui-primitives/README.zh.md
This commit is contained in:
commit
ef0a2b2e3d
4305 files changed
+174021
-23401
No files matched your search
@@ -15,8 +15,8 @@
|
||||
en: 1fdf707322fb6996
|
||||
zh: 0204f7e9d4aa3cce
|
||||
/session-controller/use-this-package:
|
||||
en: d26a58a8f2406cd5
|
||||
zh: 82135eceb9adb628
|
||||
en: f9e4042fae88f801
|
||||
zh: f379f78966d57c2e
|
||||
/session-controller/client-references:
|
||||
en: 41bdda1ac32df558
|
||||
zh: aa5d0e325924066b
|
||||
|
||||
@@ -33,13 +33,15 @@ The Client journal validates current Session event envelopes before publishing f
|
||||
|
||||
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's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating 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. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
|
||||
|
||||
Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
|
||||
|
||||
List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections.
|
||||
|
||||
Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints.
|
||||
|
||||
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.
|
||||
`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`; both checks precede preset resolution. Creations naming one identity run in arrival order, including their preset resolution, and each response reports the preset its own request left the Session running. `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. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children.
|
||||
|
||||
|
||||
@@ -33,13 +33,15 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当
|
||||
|
||||
每个 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 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
|
||||
|
||||
每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
|
||||
|
||||
列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。
|
||||
|
||||
Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。
|
||||
|
||||
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
|
||||
|
||||
显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。
|
||||
`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`;这两项检查先于 preset 解析。指明同一标识的创建请求按到达顺序执行,preset 解析也包含在内,每个响应报告其自身请求完成时该 Session 运行的 preset。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。
|
||||
|
||||
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "api-session-controller"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "会话控制接口",
|
||||
"description": "向客户端提供会话读取、运行控制和状态更新能力。"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-session-controller",
|
||||
"description": "Session Remote commands, cold reads, and live control transport",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"version": "0.2.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -39,7 +39,8 @@
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
@@ -58,6 +59,7 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
@@ -73,7 +75,7 @@
|
||||
"@deepseek-ai/dsh-deque": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"mime-types": "^3.0.2",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
@@ -120,6 +122,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Agent activation, composition, and model-selection policy owned by API Session. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
@@ -8,11 +9,12 @@ import type {
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { ModelSelection } from './types.ts'
|
||||
|
||||
@@ -42,21 +44,49 @@ export class ApiSessionCwdConflict extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit-id creation attempted to adopt a Session under another preset. */
|
||||
/** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */
|
||||
export class ApiSessionPresetConflict extends Error {
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
readonly requestedPreset: string,
|
||||
readonly existingPreset: string | undefined,
|
||||
) {
|
||||
super(
|
||||
existingPreset === undefined
|
||||
? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`
|
||||
: `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`,
|
||||
)
|
||||
super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A blank Session chosen for reuse started a turn before this Host acquired its writer. */
|
||||
class ApiSessionStartedSinceObserved extends Error {}
|
||||
|
||||
/** Placement and Agent preset for one Session created for a new conversation. */
|
||||
export interface ApiSessionCreateRequest {
|
||||
/** Blank Session to reuse, or the identity of a new Session when none is stored under it. */
|
||||
readonly sessionId?: SessionId
|
||||
/** Directory the Session owns. */
|
||||
readonly cwd: string
|
||||
/** Preset the Session runs; the configured default when omitted. */
|
||||
readonly agentPreset?: string
|
||||
/**
|
||||
* Placement rule the caller owns, such as Workspace membership and archive state.
|
||||
* @param sessionId - existing Session named by the request.
|
||||
* @returns whether that Session may be reused.
|
||||
*/
|
||||
readonly reusable: (sessionId: SessionId) => boolean
|
||||
}
|
||||
|
||||
/** The Session one creation request created or reused. */
|
||||
export interface ApiSessionCreated {
|
||||
/** Live Agent of the Session. */
|
||||
readonly agent: Agent
|
||||
/** Preset the Session ran when the request finished, before a later request for the same identity; absent without a roster. */
|
||||
readonly agentPreset: string | undefined
|
||||
}
|
||||
|
||||
/** Resolved preset and pre-publication Agent setup for one create or resume. */
|
||||
interface ApiSessionComposition {
|
||||
readonly agentPreset?: string
|
||||
readonly setup: AgentSetup
|
||||
}
|
||||
|
||||
/** Failures produced while resolving one ordinary Session identity to its live Agent. */
|
||||
export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'>
|
||||
|
||||
@@ -139,7 +169,7 @@ export async function inspectApiSession(
|
||||
/** Owns every operation that may create, resume, or configure a Web Agent. */
|
||||
export class ApiSessionAgentController {
|
||||
private readonly resumes = new Map<SessionId, Promise<Agent>>()
|
||||
private readonly creations = new Map<SessionId, Promise<Agent>>()
|
||||
private readonly claims = new Map<SessionId, Promise<void>>()
|
||||
private readonly selections = new WeakMap<Agent, InstalledSelection>()
|
||||
private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
|
||||
|
||||
@@ -229,50 +259,26 @@ export class ApiSessionAgentController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one requested identity, creating or resuming it once.
|
||||
* @param sessionId - requested Session identity.
|
||||
* @param cwd - directory the Session must own.
|
||||
* @param checkPersistedIdentity - whether to inspect a cold identity before creation.
|
||||
* @param presetId - optional Agent preset the Session must own.
|
||||
* @returns the matching live ordinary Agent.
|
||||
* Create a Session for a new conversation, reusing the named blank Session
|
||||
* while it is still reusable. A reused Session runs the requested preset, or
|
||||
* the configured default when the request names none; the preset it recorded
|
||||
* is not resolved, and a change is logged as `agent-preset/selected` before
|
||||
* the Agent is published. A named Session that has started a turn, fails the
|
||||
* caller's placement rule, or is written by another owner stays unchanged,
|
||||
* and a new Session is created instead. Requests naming one identity run in
|
||||
* arrival order, including their preset resolution, and check the named
|
||||
* Session's directory and ownership before resolving the preset.
|
||||
* @param request - optional Session to reuse, directory, preset, and placement rule.
|
||||
* @returns the reused or created Session and the preset it ran when this request finished.
|
||||
* @throws ApiSessionCwdConflict when the named Session belongs to another directory.
|
||||
* @throws ApiSessionSubagentOwnership when subagent routing owns the named Session.
|
||||
* @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset.
|
||||
* @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session.
|
||||
*/
|
||||
async ensureSession(
|
||||
sessionId: SessionId,
|
||||
cwd: string,
|
||||
checkPersistedIdentity: boolean,
|
||||
presetId?: string,
|
||||
): Promise<Agent> {
|
||||
let creation = this.creations.get(sessionId)
|
||||
if (creation === undefined) {
|
||||
creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId)
|
||||
.catch((error: unknown) => {
|
||||
const live = this.ctx.agents.get(sessionId)
|
||||
if (live !== undefined) {
|
||||
if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
return live
|
||||
}
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
.finally(() => { this.creations.delete(sessionId) })
|
||||
this.creations.set(sessionId, creation)
|
||||
}
|
||||
const agent = await creation
|
||||
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
if (presetId !== undefined) {
|
||||
this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session))
|
||||
}
|
||||
if (agent.session.header.cwd !== cwd) {
|
||||
throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
||||
}
|
||||
return agent
|
||||
async createSession(request: ApiSessionCreateRequest): Promise<ApiSessionCreated> {
|
||||
const named = request.sessionId
|
||||
if (named === undefined) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset)))
|
||||
return this.inOrder(named, async () => this.created(await this.claim(named, request)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,10 +384,7 @@ export class ApiSessionAgentController {
|
||||
* @param presetId - requested preset or the configured default when omitted.
|
||||
* @returns the resolved preset identity and Agent setup callback.
|
||||
*/
|
||||
async composeAgent(presetId: string | undefined): Promise<{
|
||||
readonly agentPreset?: string
|
||||
readonly setup: AgentSetup
|
||||
}> {
|
||||
async composeAgent(presetId: string | undefined): Promise<ApiSessionComposition> {
|
||||
const presets = this.ctx.get('agentPresets')
|
||||
if (presets === undefined) {
|
||||
return { setup: (_agentCtx, agent) => { this.installSelection(agent) } }
|
||||
@@ -441,48 +444,128 @@ export class ApiSessionAgentController {
|
||||
})).agent
|
||||
}
|
||||
|
||||
private async createOrAdopt(
|
||||
sessionId: SessionId,
|
||||
cwd: string,
|
||||
checkPersistedIdentity: boolean,
|
||||
presetId: string | undefined,
|
||||
): Promise<Agent> {
|
||||
private async inOrder<Value>(sessionId: SessionId, operation: () => Promise<Value>): Promise<Value> {
|
||||
const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation)
|
||||
const settled = result.then(() => undefined, () => undefined)
|
||||
this.claims.set(sessionId, settled)
|
||||
try {
|
||||
return await result
|
||||
} finally {
|
||||
if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */
|
||||
private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise<Agent> {
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
const live = this.ctx.agents.get(sessionId)
|
||||
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
if (live !== undefined) return live
|
||||
|
||||
if (checkPersistedIdentity) {
|
||||
try {
|
||||
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
|
||||
if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
if (observation.header.cwd !== cwd) {
|
||||
throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd)
|
||||
}
|
||||
const storedPreset = this.presetForObservation(observation)
|
||||
this.assertPresetUnchanged(sessionId, presetId, storedPreset)
|
||||
const composition = await this.composeAgent(storedPreset)
|
||||
return (await this.ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: this.agentOptions(),
|
||||
setup: composition.setup,
|
||||
})).agent
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof SessionQueryError)
|
||||
|| error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
|
||||
if (live !== undefined) {
|
||||
if (live.session.header.cwd !== request.cwd) {
|
||||
throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd)
|
||||
}
|
||||
const composition = await this.composeAgent(request.agentPreset)
|
||||
return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition)
|
||||
}
|
||||
let observation: SessionObservation
|
||||
try {
|
||||
observation = await this.ctx.sessionQuery.observeSession(sessionId)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
|
||||
return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset))
|
||||
}
|
||||
let reusable: boolean
|
||||
{
|
||||
using observed = observation
|
||||
if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
if (observed.header.cwd !== request.cwd) {
|
||||
throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd)
|
||||
}
|
||||
reusable = request.reusable(sessionId) && !observed.events.some(event => event.type === 'turn/start')
|
||||
}
|
||||
const composition = await this.composeAgent(request.agentPreset)
|
||||
if (!reusable) return this.createMinted(request.cwd, composition)
|
||||
this.assertPresetApplicable(sessionId, request.agentPreset)
|
||||
try {
|
||||
return (await this.ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: this.agentOptions(),
|
||||
setup: this.reuseSetup(composition),
|
||||
})).agent
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition)
|
||||
if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error
|
||||
// Another resume in this process may hold the writer for the Agent it published.
|
||||
return this.ctx.agents.get(sessionId) === undefined
|
||||
? this.createMinted(request.cwd, composition)
|
||||
: this.claim(sessionId, request)
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */
|
||||
private async reuseLive(
|
||||
live: Agent,
|
||||
request: ApiSessionCreateRequest,
|
||||
composition: ApiSessionComposition,
|
||||
): Promise<Agent | undefined> {
|
||||
if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined
|
||||
this.assertPresetApplicable(live.id, request.agentPreset)
|
||||
const current = this.presetForSession(live.session)
|
||||
const presets = this.ctx.get('agentPresets')
|
||||
const target = composition.agentPreset
|
||||
if (presets === undefined || target === undefined || target === current) return live
|
||||
try {
|
||||
await presets.select(live, target)
|
||||
} catch (error: unknown) {
|
||||
if (remoteErrorOf(error)?.code !== 'agent-preset/locked') throw error
|
||||
return undefined
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
/** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */
|
||||
private reuseSetup(composition: ApiSessionComposition): AgentSetup {
|
||||
return async (agentCtx, agent) => {
|
||||
if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved()
|
||||
const commit = await composition.setup(agentCtx, agent)
|
||||
const target = composition.agentPreset
|
||||
if (target !== undefined && this.presetForSession(agent.session) !== target) {
|
||||
agent.session.append('agent-preset/selected', { agentPreset: target })
|
||||
}
|
||||
return commit
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether no turn has started, by the predicate the preset registry's `select` enforces. */
|
||||
private isBlank(session: Session): boolean {
|
||||
const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')
|
||||
return boundary === undefined || (boundary.openTurnStartSeq === null && boundary.lastTurn === 0)
|
||||
}
|
||||
|
||||
/** Without a roster, reuse cannot apply a named preset. */
|
||||
private assertPresetApplicable(sessionId: SessionId, requested: string | undefined): void {
|
||||
if (requested === undefined || this.ctx.get('agentPresets') !== undefined) return
|
||||
throw new ApiSessionPresetConflict(sessionId, requested)
|
||||
}
|
||||
|
||||
private created(agent: Agent): ApiSessionCreated {
|
||||
return { agent, agentPreset: this.presetForSession(agent.session) }
|
||||
}
|
||||
|
||||
private createMinted(cwd: string, composition: ApiSessionComposition): Promise<Agent> {
|
||||
return this.createAgent(brandString<SessionId>(`session-${randomUUID()}`), cwd, composition)
|
||||
}
|
||||
|
||||
private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise<Agent> {
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
|
||||
}
|
||||
const composition = await this.composeAgent(presetId)
|
||||
return (await this.ctx.agents.create({
|
||||
sessionId,
|
||||
agentOptions: this.agentOptions(),
|
||||
@@ -514,15 +597,6 @@ export class ApiSessionAgentController {
|
||||
}
|
||||
return observation.projections.values.agentPreset ?? undefined
|
||||
}
|
||||
|
||||
private assertPresetUnchanged(
|
||||
sessionId: SessionId,
|
||||
requested: string | undefined,
|
||||
existing: string | undefined,
|
||||
): void {
|
||||
if (requested === undefined || requested === existing) return
|
||||
throw new ApiSessionPresetConflict(sessionId, requested, existing)
|
||||
}
|
||||
}
|
||||
|
||||
function agentModelSelection(selection: ModelSelection): AgentModelSelection {
|
||||
|
||||
@@ -79,14 +79,17 @@ export interface ISessions {
|
||||
*/
|
||||
readonly searchResultLimit: number
|
||||
/**
|
||||
* Create or adopt a Session on the Host.
|
||||
* @param opts - target workspace, directory, and optional preallocated identity.
|
||||
* @returns the catalogued identity; retain it before borrowing its binding.
|
||||
* Create a Session for a new conversation on the Host. The Host reuses the
|
||||
* named blank Session while it is reusable and otherwise creates another; the
|
||||
* Session runs the named preset, or the Host default when none is named.
|
||||
* @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset.
|
||||
* @returns the catalogued identity the Host created or reused; retain it before borrowing its binding.
|
||||
*/
|
||||
create(opts?: {
|
||||
workspaceId?: WorkspaceId
|
||||
cwd?: string
|
||||
sessionId?: SessionId
|
||||
agentPreset?: string
|
||||
}): Promise<SessionId>
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
|
||||
@@ -502,17 +502,21 @@ export class SessionManager {
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh). A created session is blank by definition
|
||||
* (entity birth precedes the first message).
|
||||
* @param opts - target workspace or working directory, plus an optional caller-owned id.
|
||||
* @returns the create result.
|
||||
* @param opts - target workspace or working directory, an optional blank Session to reuse, and an optional Agent preset.
|
||||
* @returns the create result, naming the Session the Host created or reused.
|
||||
*/
|
||||
async create(
|
||||
opts: {
|
||||
workspaceId?: WorkspaceId
|
||||
cwd?: string
|
||||
sessionId?: SessionId
|
||||
agentPreset?: string
|
||||
} = {},
|
||||
): Promise<RemoteResult<{ sessionId: SessionId }>> {
|
||||
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
|
||||
const shared = {
|
||||
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
|
||||
...(opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }),
|
||||
}
|
||||
const payload = opts.workspaceId !== undefined
|
||||
? { workspaceId: opts.workspaceId, ...shared }
|
||||
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
|
||||
|
||||
@@ -427,11 +427,16 @@ export class ClientSessions implements ISessions {
|
||||
/**
|
||||
* Create a Host Session and publish its catalog row before resolving.
|
||||
* Callers retain the returned identity before borrowing its binding.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset.
|
||||
* @returns the created or reused session id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
async create(opts: {
|
||||
workspaceId?: WorkspaceId
|
||||
cwd?: string
|
||||
sessionId?: SessionId
|
||||
agentPreset?: string
|
||||
} = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
|
||||
@@ -289,7 +289,7 @@ export class Session implements SessionFace {
|
||||
// wire type is used; this array is not filtered or reordered.
|
||||
const routedContent = content as Exclude<PromptContentPart, { readonly type: 'file' }>[]
|
||||
const routed = await this.remote.subagents.prompt({
|
||||
requestId: randomUUID() as SessionRequestId,
|
||||
requestId: requestId ?? randomUUID() as SessionRequestId,
|
||||
parentSessionId: this.address.parentSessionId,
|
||||
childSessionId: this.address.childSessionId,
|
||||
mode: 'continuable',
|
||||
|
||||
@@ -20,7 +20,6 @@ import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
apiSessionSubagentOwnershipError,
|
||||
hasApiSessionSubagentOwner,
|
||||
inspectApiSession,
|
||||
type ApiSessionCreated,
|
||||
} from './agent.ts'
|
||||
import type {
|
||||
SessionAttachmentRequest,
|
||||
@@ -64,7 +64,12 @@ type PromptContentCandidate =
|
||||
| SessionPromptRequest['content'][number]
|
||||
| Extract<SessionUpdateQueueRequest['action'], { readonly kind: 'edit' }>['content'][number]
|
||||
|
||||
function hasPromptContent(content: readonly PromptContentCandidate[]): boolean {
|
||||
/**
|
||||
* Whether prompt content carries non-whitespace text or an attachment.
|
||||
* @param content - submitted or edited prompt parts.
|
||||
* @returns false for content the Agent must not receive.
|
||||
*/
|
||||
export function hasPromptContent(content: readonly PromptContentCandidate[]): boolean {
|
||||
return content.some(part => part.type !== 'text' || part.text.trim().length > 0)
|
||||
}
|
||||
|
||||
@@ -98,15 +103,15 @@ export class SessionCommandController {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create or idempotently adopt one ordinary Session.
|
||||
* @param request - requested identity, location, and Agent preset.
|
||||
* @returns the Session identity and resolved preset when configured.
|
||||
* Create one ordinary Session for a new conversation, reusing the named
|
||||
* blank Session while it is an unarchived member of the requested Workspace.
|
||||
* @param request - optional blank Session to reuse, location, and Agent preset.
|
||||
* @returns the created or reused Session identity and its preset when configured.
|
||||
*/
|
||||
async create(request: SessionCreateRequest): Promise<SessionCreateValue> {
|
||||
if (request.workspaceId !== undefined && request.cwd !== undefined) {
|
||||
throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})
|
||||
}
|
||||
const sessionId = request.sessionId ?? brandString<SessionId>(`session-${randomUUID()}`)
|
||||
let workspace: Workspace | undefined
|
||||
if (request.workspaceId !== undefined) {
|
||||
workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
|
||||
@@ -117,17 +122,19 @@ export class SessionCommandController {
|
||||
}
|
||||
}
|
||||
const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
|
||||
let adopted: Agent
|
||||
let created: ApiSessionCreated
|
||||
try {
|
||||
adopted = await this.agents.ensureSession(
|
||||
sessionId,
|
||||
created = await this.agents.createSession({
|
||||
...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }),
|
||||
cwd,
|
||||
request.sessionId !== undefined,
|
||||
request.agentPreset,
|
||||
)
|
||||
...(request.agentPreset === undefined ? {} : { agentPreset: request.agentPreset }),
|
||||
reusable: sessionId => !this.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)
|
||||
&& (workspace === undefined || workspace.sessionIds.includes(sessionId)),
|
||||
})
|
||||
} catch (error) {
|
||||
this.rejectCreation(sessionId, error)
|
||||
this.rejectCreation(error)
|
||||
}
|
||||
const sessionId = created.agent.id
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(sessionId)
|
||||
@@ -139,8 +146,7 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
}
|
||||
const agentPreset = this.agents.presetForSession(adopted.session)
|
||||
return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }
|
||||
return { sessionId, ...(created.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,35 +310,15 @@ export class SessionCommandController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject empty content, then admit one prompt after Agent and attachment validation.
|
||||
* @param request - Session identity, prompt content, source metadata, and delivery mode.
|
||||
* Admit one prompt after Agent and attachment validation; the Remote method
|
||||
* has already validated the content and built the durable message source.
|
||||
* @param request - Session identity, prompt content, and delivery mode.
|
||||
* @param source - the `user-rpc` source recorded on the accepted message.
|
||||
* @returns acknowledgement that the Agent accepted the prompt.
|
||||
*/
|
||||
async prompt(request: SessionPromptRequest): Promise<SessionPromptValue> {
|
||||
if (!hasPromptContent(request.content)) {
|
||||
throw new RemoteError(
|
||||
'gateway/bad-request',
|
||||
'prompt content must include non-whitespace text or an attachment',
|
||||
{},
|
||||
)
|
||||
}
|
||||
const clientTimeZone = request.clientTimeZone === undefined
|
||||
? undefined
|
||||
: canonicalClientTimeZone(request.clientTimeZone)
|
||||
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/invalid-time-zone',
|
||||
'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
{ value: request.clientTimeZone },
|
||||
)
|
||||
}
|
||||
async prompt(request: SessionPromptRequest, source: MessageSource): Promise<SessionPromptValue> {
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
|
||||
const source: MessageSource = {
|
||||
kind: 'user',
|
||||
rpcId: request.requestId,
|
||||
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
|
||||
}
|
||||
const hasImage = request.content.some(part => part.type === 'image')
|
||||
const admit = async (): Promise<SessionPromptValue> => {
|
||||
try {
|
||||
@@ -530,16 +516,12 @@ export class SessionCommandController {
|
||||
return found.agent
|
||||
}
|
||||
|
||||
private rejectCreation(sessionId: SessionId, error: unknown): never {
|
||||
private rejectCreation(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,
|
||||
requestedPreset: error.requestedPreset,
|
||||
...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }),
|
||||
})
|
||||
}
|
||||
if (error instanceof ApiSessionCwdConflict) {
|
||||
@@ -552,7 +534,7 @@ export class SessionCommandController {
|
||||
if (error instanceof ApiSessionSubagentOwnership) {
|
||||
throw apiSessionSubagentOwnershipError(error.sessionId)
|
||||
}
|
||||
throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {})
|
||||
throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {})
|
||||
}
|
||||
|
||||
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
|
||||
|
||||
@@ -6,20 +6,23 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, 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 {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access'
|
||||
import {
|
||||
ApiSessionAgentController,
|
||||
inspectApiSession,
|
||||
type ApiSessionAgentResult,
|
||||
} from './agent.ts'
|
||||
import { SessionCommandController } from './commands.ts'
|
||||
import { SessionCommandController, hasPromptContent } from './commands.ts'
|
||||
import { SessionControlController } from './control.ts'
|
||||
import { SessionHistoryController } from './history.ts'
|
||||
import { SessionFileReferences } from './file-references.ts'
|
||||
@@ -32,6 +35,7 @@ import { ArchivedSessionGate } from './archived-session-gate.ts'
|
||||
import type {
|
||||
ModelCatalog,
|
||||
SessionWorkspacePathApplication,
|
||||
SessionAddress,
|
||||
SessionAttachmentRequest,
|
||||
SessionAttachmentValue,
|
||||
SessionCancelRequest,
|
||||
@@ -76,6 +80,16 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Session resource a history address names: the Session itself, or
|
||||
* the child Session whose transcript a subagent address reads.
|
||||
* @param request - history request carrying the durable address.
|
||||
* @returns the Session whose access the read requires.
|
||||
*/
|
||||
function addressedSession(request: { readonly address: SessionAddress }): SessionId {
|
||||
return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId
|
||||
}
|
||||
|
||||
/** Session Controller deployment policy. */
|
||||
export interface Config {
|
||||
/** Override platform desktop-opener detection. */
|
||||
@@ -110,6 +124,7 @@ export class SessionController extends TypertRemoteService {
|
||||
'fileUploads',
|
||||
'fs',
|
||||
'llm',
|
||||
'remoteAccess',
|
||||
'sessions',
|
||||
'sessionProjections',
|
||||
'sessionQuery',
|
||||
@@ -141,6 +156,7 @@ export class SessionController extends TypertRemoteService {
|
||||
*/
|
||||
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
|
||||
super(ctx, 'sessionController', { namespace: 'session' })
|
||||
ctx.typert.resources.register('session', { field: 'sessionId' })
|
||||
const resolved = SessionController.Config(config)
|
||||
installModelSelectionProjection(ctx)
|
||||
this.agents = new ApiSessionAgentController(ctx)
|
||||
@@ -258,24 +274,28 @@ export class SessionController extends TypertRemoteService {
|
||||
*/
|
||||
@Remote('list')
|
||||
async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue> {
|
||||
return { items: await this.listState.list(signal) }
|
||||
const items = await this.listState.list(signal)
|
||||
return { items: items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visible Session content without resuming an Agent.
|
||||
* Search visible Session content without resuming an Agent. A non-owner
|
||||
* receives only the hits whose Session `allows()` admits; `hasMore`
|
||||
* describes the bounded result before that filtering.
|
||||
* @param request - literal message-content query.
|
||||
* @param signal - cancellation for list and search reads.
|
||||
* @returns authorized bounded Session search results.
|
||||
*/
|
||||
@Remote('search')
|
||||
search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
|
||||
return this.listState.search(request.query, signal)
|
||||
async search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
|
||||
const result = await this.listState.search(request.query, signal)
|
||||
return { ...result, items: result.items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or idempotently adopt one ordinary Session.
|
||||
* @param request - requested identity, location, and Agent preset.
|
||||
* @returns the Session identity and resolved preset when configured.
|
||||
* Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable.
|
||||
* @param request - optional blank Session to reuse, location, and Agent preset.
|
||||
* @returns the created or reused Session identity and its preset when configured.
|
||||
*/
|
||||
@Remote('create')
|
||||
create(request: SessionCreateRequest): Promise<SessionCreateValue> {
|
||||
@@ -439,9 +459,30 @@ export class SessionController extends TypertRemoteService {
|
||||
* @returns acknowledgement that the Agent accepted the prompt.
|
||||
*/
|
||||
@Remote('prompt')
|
||||
prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
|
||||
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
|
||||
signal.throwIfAborted()
|
||||
return this.commands.prompt(request)
|
||||
if (!hasPromptContent(request.content)) {
|
||||
throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {})
|
||||
}
|
||||
const clientTimeZone = request.clientTimeZone === undefined
|
||||
? undefined
|
||||
: canonicalClientTimeZone(request.clientTimeZone)
|
||||
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/invalid-time-zone',
|
||||
'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
{ value: request.clientTimeZone },
|
||||
)
|
||||
}
|
||||
// The caller is recorded only when it is not the owner, so owner messages keep their exact source.
|
||||
const principal = principalOf(this.ctx.invocation)
|
||||
const source: MessageSource = {
|
||||
kind: 'user',
|
||||
rpcId: request.requestId,
|
||||
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
|
||||
...(principal === undefined ? {} : { principal }),
|
||||
}
|
||||
return this.commands.prompt(request, source)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -480,7 +521,7 @@ export class SessionController extends TypertRemoteService {
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page.
|
||||
*/
|
||||
@Remote('page')
|
||||
@Remote({ name: 'page', resources: { session: addressedSession } })
|
||||
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
return this.history.page(request, signal)
|
||||
}
|
||||
@@ -492,7 +533,7 @@ export class SessionController extends TypertRemoteService {
|
||||
* @returns a complete opening snapshot followed by gap-free durable event
|
||||
* frames and optional cursorless assistant-stream frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
@Remote({ mode: 'stream', resources: { session: addressedSession } })
|
||||
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
return this.history.follow(request, signal)
|
||||
}
|
||||
@@ -555,8 +596,23 @@ export class SessionController extends TypertRemoteService {
|
||||
* @returns one complete baseline followed by live replacement frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
|
||||
return this.controlState.control(signal)
|
||||
async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
|
||||
for await (const frame of this.controlState.control(signal)) {
|
||||
switch (frame.type) {
|
||||
case 'baseline': {
|
||||
const projections = Object.fromEntries(Object.entries(frame.value.projections)
|
||||
.filter(([sessionId]) => this.ctx.remoteAccess.allows({ kind: 'session', id: sessionId as SessionId })))
|
||||
yield { type: 'baseline', value: { ...frame.value, projections } }
|
||||
break
|
||||
}
|
||||
case 'projection':
|
||||
if (this.ctx.remoteAccess.allows({ kind: 'session', id: frame.sessionId })) yield frame
|
||||
break
|
||||
/* v8 ignore next -- closed control-frame union. */
|
||||
default:
|
||||
assertNever(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { TextBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { Principal } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
|
||||
@@ -219,7 +220,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'agent-preset/conflict': {
|
||||
readonly sessionId: SessionId
|
||||
readonly requestedPreset: string
|
||||
readonly existingPreset?: string
|
||||
}
|
||||
'session/attachment-invalid': { readonly reason: string }
|
||||
'session/queue-item-not-found': { readonly itemId: MessageId }
|
||||
@@ -284,17 +284,23 @@ export interface SessionSearchValue {
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
|
||||
/** Session creation or explicit-id adoption request. */
|
||||
/** Request for a Session for a new conversation. */
|
||||
export interface SessionCreateRequest {
|
||||
/** Workspace the Session joins; exclusive with `cwd`. */
|
||||
readonly workspaceId?: WorkspaceId
|
||||
/** Directory the Session owns when no Workspace is named. */
|
||||
readonly cwd?: string
|
||||
/** Blank Session to reuse while it is reusable, or the identity of a new Session when none is stored under it. */
|
||||
readonly sessionId?: SessionId
|
||||
/** Preset the Session runs, replacing a reused Session's recorded preset; the configured default when omitted. */
|
||||
readonly agentPreset?: string
|
||||
}
|
||||
|
||||
/** Session creation response value. */
|
||||
export interface SessionCreateValue {
|
||||
/** The created or reused Session, which may differ from the requested `sessionId`. */
|
||||
readonly sessionId: SessionId
|
||||
/** Preset the Session runs, when the deployment composes a preset roster. */
|
||||
readonly agentPreset?: string
|
||||
}
|
||||
|
||||
@@ -404,8 +410,12 @@ export type SessionRequestId = Branded<'session-request-id'>
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
/** Browser prompt correlation and optional Host-validated time zone. */
|
||||
'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string }
|
||||
/**
|
||||
* Browser prompt correlation, optional Host-validated time zone, and the
|
||||
* caller that submitted the prompt. `principal` is written only for a
|
||||
* caller other than the owner, so an absent `principal` means the owner.
|
||||
*/
|
||||
'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ApiSessionAgentController,
|
||||
ApiSessionCwdConflict,
|
||||
ApiSessionNotFound,
|
||||
ApiSessionSubagentOwnership,
|
||||
inspectApiSession,
|
||||
} from '../src/agent.ts'
|
||||
import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
|
||||
@@ -329,123 +328,8 @@ describe('ApiSession model selection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiSession create or adoption', () => {
|
||||
it('shares one in-flight creation between concurrent callers', async () => {
|
||||
const { ctx, agents } = await harness()
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-'))
|
||||
tempDirs.push(cwd)
|
||||
const meta = header('concurrent-create', cwd)
|
||||
const created = unpublishedAgent(ctx, meta)
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => {
|
||||
await gate
|
||||
return { agent: created, dispose: () => Promise.resolve() }
|
||||
})
|
||||
|
||||
const first = agents.ensureSession(meta.id, cwd, false)
|
||||
const second = agents.ensureSession(meta.id, cwd, false)
|
||||
release()
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([created, created])
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts a raced ordinary creation and rejects a raced attached child', async () => {
|
||||
const ordinary = await harness()
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-'))
|
||||
tempDirs.push(cwd)
|
||||
const ordinaryMeta = header('create-race', cwd)
|
||||
const winner = agent(ordinary.ctx, ordinaryMeta)
|
||||
vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
|
||||
await ordinary.ctx.agents.register(winner)
|
||||
throw new Error('raced creation')
|
||||
})
|
||||
await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false))
|
||||
.resolves.toBe(winner)
|
||||
|
||||
const child = await harness()
|
||||
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-'))
|
||||
tempDirs.push(childCwd)
|
||||
const childId = SessionId('create-child-race')
|
||||
vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => {
|
||||
child.ctx.sessions.create(childId, {
|
||||
meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' },
|
||||
})
|
||||
throw new Error('raced child creation')
|
||||
})
|
||||
await expect(child.agents.ensureSession(childId, childCwd, false))
|
||||
.rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
|
||||
})
|
||||
|
||||
it('validates ownership and cwd on the Agent returned by creation', async () => {
|
||||
const child = await harness()
|
||||
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-'))
|
||||
tempDirs.push(childCwd)
|
||||
const childMeta = {
|
||||
...header('returned-child', childCwd),
|
||||
parentSession: SessionId('parent'),
|
||||
origin: 'subagent' as const,
|
||||
}
|
||||
const childAgent = unpublishedAgent(child.ctx, childMeta)
|
||||
vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({
|
||||
agent: childAgent,
|
||||
dispose: () => Promise.resolve(),
|
||||
})
|
||||
await expect(child.agents.ensureSession(childMeta.id, childCwd, false))
|
||||
.rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
|
||||
|
||||
const wrong = await harness()
|
||||
const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-'))
|
||||
tempDirs.push(requestedCwd)
|
||||
const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other'))
|
||||
vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({
|
||||
agent: wrongAgent,
|
||||
dispose: () => Promise.resolve(),
|
||||
})
|
||||
await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false))
|
||||
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
|
||||
})
|
||||
|
||||
it('resumes a matching persisted identity and preserves its selected preset', async () => {
|
||||
const { ctx, agents } = await harness()
|
||||
const meta = { ...header('stored'), agentPreset: 'minimal' }
|
||||
const events = [{
|
||||
type: 'agent-preset/selected',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { agentPreset: 'minimal' },
|
||||
}] as SessionEvent[]
|
||||
providePersistence(ctx, {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
})
|
||||
ctx.provide('agentPresets', {
|
||||
resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }),
|
||||
mount: () => Promise.resolve(),
|
||||
} as never)
|
||||
const resumed = {
|
||||
id: meta.id,
|
||||
session: {
|
||||
id: meta.id,
|
||||
header: meta,
|
||||
snapshotEvents: () => events,
|
||||
eventAt: (seq: number) => events[seq],
|
||||
seq: events.length,
|
||||
},
|
||||
status: 'idle',
|
||||
ctx,
|
||||
} as unknown as Agent
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
|
||||
agent: resumed,
|
||||
dispose: () => Promise.resolve(),
|
||||
})
|
||||
|
||||
await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed)
|
||||
expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
|
||||
})
|
||||
|
||||
it('rejects an ownership race before resume and a persisted cwd conflict', async () => {
|
||||
describe('ApiSession creation', () => {
|
||||
it('rejects a resume that subagent routing adopts before publication', async () => {
|
||||
const child = await harness()
|
||||
const childMeta = header('resume-child-race')
|
||||
providePersistence(child.ctx, {
|
||||
@@ -464,15 +348,6 @@ describe('ApiSession create or adoption', () => {
|
||||
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
|
||||
error: { code: 'session/agent-busy' },
|
||||
})
|
||||
|
||||
const conflict = await harness()
|
||||
const stored = header('stored-cwd-conflict', '/stored')
|
||||
providePersistence(conflict.ctx, {
|
||||
list: () => Promise.resolve([stored]),
|
||||
inspect: () => Promise.resolve({ meta: stored, events: [] }),
|
||||
})
|
||||
await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
|
||||
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
|
||||
})
|
||||
|
||||
it('surfaces directory creation failure', async () => {
|
||||
@@ -481,7 +356,7 @@ describe('ApiSession create or adoption', () => {
|
||||
tempDirs.push(parent)
|
||||
const file = join(parent, 'file')
|
||||
writeFileSync(file, 'not a directory')
|
||||
await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))
|
||||
await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true }))
|
||||
.rejects.toThrow('failed to ensure project directory')
|
||||
})
|
||||
})
|
||||
@@ -5,11 +5,14 @@ import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApiSessionAgentController,
|
||||
ApiSessionCwdConflict,
|
||||
type ApiSessionCreated,
|
||||
type ApiSessionCreateRequest,
|
||||
} from '../src/agent.ts'
|
||||
import { SessionCommandController } from '../src/commands.ts'
|
||||
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
|
||||
@@ -18,18 +21,31 @@ async function expectFailure(operation: Promise<unknown>, code: string): Promise
|
||||
await expect(operation).rejects.toMatchObject({ code })
|
||||
}
|
||||
|
||||
function controllerAgents(overrides: object = {}): ApiSessionAgentController {
|
||||
return {
|
||||
ensureSession: () => Promise.resolve(),
|
||||
composeAgent: () => Promise.resolve({ setup: () => {} }),
|
||||
presetForSession: () => undefined,
|
||||
presetForObservation: () => undefined,
|
||||
...overrides,
|
||||
} as unknown as ApiSessionAgentController
|
||||
/** The Agent controller with a fixed Agent composition, and optionally a fixed creation outcome. */
|
||||
function controllerAgents(
|
||||
ctx: Context,
|
||||
fixed: {
|
||||
readonly composeAgent?: ApiSessionAgentController['composeAgent']
|
||||
readonly createSession?: ApiSessionAgentController['createSession']
|
||||
} = {},
|
||||
): ApiSessionAgentController {
|
||||
const agents = new ApiSessionAgentController(ctx)
|
||||
vi.spyOn(agents, 'composeAgent').mockImplementation(fixed.composeAgent ?? (() => Promise.resolve({ setup: () => {} })))
|
||||
if (fixed.createSession !== undefined) vi.spyOn(agents, 'createSession').mockImplementation(fixed.createSession)
|
||||
return agents
|
||||
}
|
||||
|
||||
/** A creation outcome that publishes `sessionId` in `cwd` as a stand-in Agent. */
|
||||
function publishedSession(ctx: Context, sessionId: SessionId) {
|
||||
return (request: ApiSessionCreateRequest): Promise<ApiSessionCreated> => {
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd: request.cwd } })
|
||||
return Promise.resolve({ agent: { id: session.id, session, status: 'idle', ctx } as Agent, agentPreset: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
async function baseContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
installSessionReadTestServices(ctx)
|
||||
@@ -41,29 +57,19 @@ async function baseContext(): Promise<Context> {
|
||||
}
|
||||
|
||||
describe('Session creation failures', () => {
|
||||
it('mints an identity with the default cwd when no explicit target is supplied', async () => {
|
||||
it('creates in the default cwd without a named identity when no explicit target is supplied', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
|
||||
const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => {
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd } })
|
||||
return Promise.resolve({ id: sessionId, session } as Agent)
|
||||
})
|
||||
const controller = new SessionCommandController(
|
||||
ctx,
|
||||
controllerAgents({ ensureSession }),
|
||||
'/default-workspace',
|
||||
)
|
||||
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [], archivedSessionIds: [] } as never)
|
||||
const createSession = vi.fn(publishedSession(ctx, SessionId('session-minted')))
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(ctx, { createSession }), '/default-workspace')
|
||||
|
||||
const created = await controller.create({})
|
||||
|
||||
expect(created.sessionId).toMatch(/^session-/)
|
||||
expect(created).not.toHaveProperty('agentPreset')
|
||||
expect(ensureSession).toHaveBeenCalledWith(
|
||||
created.sessionId,
|
||||
'/default-workspace',
|
||||
false,
|
||||
undefined,
|
||||
)
|
||||
expect(created).toEqual({ sessionId: 'session-minted' })
|
||||
expect(createSession).toHaveBeenCalledOnce()
|
||||
const request = createSession.mock.calls[0]?.[0]
|
||||
expect(request?.cwd).toBe('/default-workspace')
|
||||
expect(Object.keys(request ?? {}).sort()).toEqual(['cwd', 'reusable'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -72,7 +78,7 @@ describe('Session creation failures', () => {
|
||||
missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
|
||||
const missingController = new SessionCommandController(
|
||||
missing,
|
||||
controllerAgents(),
|
||||
controllerAgents(missing),
|
||||
'/default',
|
||||
)
|
||||
await expectFailure(missingController.create({
|
||||
@@ -92,7 +98,7 @@ describe('Session creation failures', () => {
|
||||
} as never)
|
||||
const failedController = new SessionCommandController(
|
||||
failed,
|
||||
controllerAgents(),
|
||||
controllerAgents(failed, { createSession: publishedSession(failed, SessionId('workspace-session')) }),
|
||||
'/default',
|
||||
)
|
||||
await expectFailure(failedController.create({
|
||||
@@ -119,10 +125,6 @@ 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',
|
||||
@@ -132,7 +134,7 @@ describe('Session creation failures', () => {
|
||||
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
|
||||
const controller = new SessionCommandController(
|
||||
ctx,
|
||||
controllerAgents({ ensureSession: () => Promise.reject(error) }),
|
||||
controllerAgents(ctx, { createSession: () => Promise.reject(error) }),
|
||||
'/default',
|
||||
)
|
||||
|
||||
@@ -144,7 +146,7 @@ describe('Session creation failures', () => {
|
||||
|
||||
it('rejects contradictory create targets', async () => {
|
||||
const ctx = await baseContext()
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
|
||||
|
||||
await expectFailure(controller.create({
|
||||
workspaceId: 'workspace-1' as WorkspaceId,
|
||||
@@ -184,7 +186,7 @@ describe('Session fork failures', () => {
|
||||
const withoutPersistence = await baseContext()
|
||||
withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
const unavailableController = new SessionCommandController(
|
||||
withoutPersistence, controllerAgents(), '/default',
|
||||
withoutPersistence, controllerAgents(withoutPersistence), '/default',
|
||||
)
|
||||
await expectFailure(unavailableController.fork({
|
||||
sessionId: SessionId('missing'),
|
||||
@@ -197,7 +199,7 @@ describe('Session fork failures', () => {
|
||||
list: () => Promise.resolve([]),
|
||||
inspect: vi.fn(),
|
||||
}) as never)
|
||||
const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
|
||||
const missingController = new SessionCommandController(missing, controllerAgents(missing), '/default')
|
||||
await expectFailure(missingController.fork({
|
||||
sessionId: SessionId('missing'),
|
||||
}), 'session/not-found')
|
||||
@@ -208,7 +210,7 @@ describe('Session fork failures', () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
|
||||
|
||||
await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -218,7 +220,7 @@ describe('Session fork failures', () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
const source = ctx.sessions.create(SessionId('empty-source'))
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
|
||||
|
||||
await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable')
|
||||
await expect(controller.fork({ sessionId: source.id, atSeq: 0 })).rejects.toMatchObject({
|
||||
@@ -237,7 +239,7 @@ describe('Session fork failures', () => {
|
||||
parentSession: SessionId('parent'),
|
||||
origin: 'subagent',
|
||||
})
|
||||
const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default')
|
||||
const lineageController = new SessionCommandController(lineage, controllerAgents(lineage), '/default')
|
||||
await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal')
|
||||
await lineage.fiber.dispose()
|
||||
|
||||
@@ -245,7 +247,7 @@ describe('Session fork failures', () => {
|
||||
creation.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
const source = completedSession(creation, 'creation-source', '/workspace')
|
||||
vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed'))
|
||||
const creationController = new SessionCommandController(creation, controllerAgents(), '/default')
|
||||
const creationController = new SessionCommandController(creation, controllerAgents(creation), '/default')
|
||||
await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal')
|
||||
await creation.fiber.dispose()
|
||||
})
|
||||
@@ -262,7 +264,7 @@ describe('Session fork failures', () => {
|
||||
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
|
||||
(options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
|
||||
)
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
|
||||
|
||||
await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed')
|
||||
const options = create.mock.calls[0]?.[0]
|
||||
@@ -279,7 +281,7 @@ describe('Session fork failures', () => {
|
||||
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
|
||||
(options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
|
||||
)
|
||||
const controller = new SessionCommandController(ctx, controllerAgents({
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(ctx, {
|
||||
composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }),
|
||||
}), '/default')
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import CommandRuntime from '@deepseek-ai/dsh-commands'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
@@ -113,6 +113,9 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */
|
||||
const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId }
|
||||
|
||||
function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
|
||||
return {
|
||||
requestId: 'req-1' as SessionRequestId,
|
||||
@@ -163,7 +166,7 @@ describe('Session file uploads', () => {
|
||||
{ type: 'file', receiptId: receipt.receiptId },
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
{ type: 'text', text: 'read it' },
|
||||
]))
|
||||
]), USER_SOURCE)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.content).toEqual([
|
||||
@@ -190,7 +193,7 @@ describe('Session file uploads', () => {
|
||||
name: 'huge.bin',
|
||||
}))
|
||||
expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
|
||||
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
|
||||
{ type: 'file', attachment: receipt.file },
|
||||
])
|
||||
@@ -217,7 +220,7 @@ describe('Session file uploads', () => {
|
||||
await expect(controller.prompt(promptRequest([
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
{ type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
|
||||
]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
|
||||
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
expect(saveImages).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -302,7 +305,7 @@ describe('Session file uploads', () => {
|
||||
const { controller, uploads, agent } = await uploadHarness()
|
||||
uploads.retirePrompt(agent, 'not-staged')
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
|
||||
uploads.retirePrompt(agent, 'other-request')
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
|
||||
@@ -317,7 +320,7 @@ describe('Session file uploads', () => {
|
||||
{ data: 'AAAA' },
|
||||
new AbortController().signal,
|
||||
)
|
||||
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]))
|
||||
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE)
|
||||
first.ctx.emit('session/event', first.agent.session, {
|
||||
type: 'user/message',
|
||||
data: createUserMessage({
|
||||
@@ -345,9 +348,9 @@ describe('Session file uploads', () => {
|
||||
it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
|
||||
const { controller, agent, followup } = await uploadHarness()
|
||||
const request = promptRequest([{ type: 'text', text: 'once' }])
|
||||
await controller.prompt(request)
|
||||
await controller.prompt(request, USER_SOURCE)
|
||||
agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
|
||||
await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
|
||||
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -364,7 +367,7 @@ describe('Session file uploads', () => {
|
||||
source: { kind: 'user', rpcId: request.requestId },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
|
||||
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -374,7 +377,7 @@ describe('Session file uploads', () => {
|
||||
saveImages.mockReturnValueOnce(admitted.promise)
|
||||
const prompting = controller.prompt(promptRequest([
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
]))
|
||||
]), USER_SOURCE)
|
||||
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
|
||||
await disposeAgent()
|
||||
admitted.resolve([{
|
||||
@@ -386,7 +389,7 @@ describe('Session file uploads', () => {
|
||||
it('rejects when a previously bound receipt retires during image admission', async () => {
|
||||
const { controller, uploads, agent, saveImages } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
|
||||
const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
|
||||
saveImages.mockReturnValueOnce(admitted.promise)
|
||||
const prompting = controller.prompt({
|
||||
@@ -395,7 +398,7 @@ describe('Session file uploads', () => {
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
]),
|
||||
requestId: 'req-2' as SessionRequestId,
|
||||
})
|
||||
}, USER_SOURCE)
|
||||
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
|
||||
uploads.retirePrompt(agent, 'req-1')
|
||||
admitted.resolve([{
|
||||
@@ -409,12 +412,12 @@ describe('Session file uploads', () => {
|
||||
it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
|
||||
const { controller, uploads, agent, followup } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
|
||||
followup.mockImplementationOnce(() => { throw new Error('busy') })
|
||||
await expect(controller.prompt({
|
||||
...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
|
||||
requestId: 'req-2' as SessionRequestId,
|
||||
})).rejects.toMatchObject({ code: 'session/agent-busy' })
|
||||
}, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
|
||||
uploads.retirePrompt(agent, 'req-1')
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
|
||||
})
|
||||
@@ -425,7 +428,7 @@ describe('Session file uploads', () => {
|
||||
followup.mockImplementationOnce(() => { throw new Error('busy') })
|
||||
await expect(controller.prompt(promptRequest([
|
||||
{ type: 'file', receiptId: receipt.receiptId },
|
||||
]))).rejects.toMatchObject({ code: 'session/agent-busy' })
|
||||
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
|
||||
uploads.retirePrompt(agent, 'req-1')
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
|
||||
})
|
||||
@@ -433,7 +436,7 @@ describe('Session file uploads', () => {
|
||||
it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
|
||||
const { controller, uploads, agent, followup } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
|
||||
const queued = followup.mock.calls[0]?.[0] as UserMessage
|
||||
agent.inbox.append('next-turn', queued)
|
||||
expect(await controller.updateQueue({
|
||||
@@ -454,7 +457,7 @@ describe('Session file uploads', () => {
|
||||
await controller.prompt(promptRequest([
|
||||
{ type: 'file', receiptId: first.receiptId },
|
||||
{ type: 'file', receiptId: second.receiptId },
|
||||
]))
|
||||
]), USER_SOURCE)
|
||||
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.content).toEqual([
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import { resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { scheduler } from 'node:timers/promises'
|
||||
import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
|
||||
import SessionController from '../src/index.ts'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiSessionList } from '../src/list.ts'
|
||||
@@ -181,10 +182,15 @@ describe('Session-list work slices', () => {
|
||||
it('routes an overridden budget through the public Session Controller', async () => {
|
||||
const h = await harness([{ id: 'first', phase: 'cold' }, { id: 'middle', phase: 'cold' }, { id: 'last', phase: 'cold' }], 4)
|
||||
const dispose = (): void => {}
|
||||
h.ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never)
|
||||
h.ctx.provide('typert', {
|
||||
lookups: { configure: () => dispose },
|
||||
contexts: { configureHost: () => dispose },
|
||||
resources: { register: () => dispose },
|
||||
} as never)
|
||||
h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never)
|
||||
h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never)
|
||||
h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never)
|
||||
new RemoteAccessService(h.ctx)
|
||||
const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false })
|
||||
const yieldWork = vi.spyOn(scheduler, 'yield')
|
||||
const result = await controller.list({}, new AbortController().signal)
|
||||
|
||||
@@ -1194,6 +1194,17 @@ describe('remaining branches', () => {
|
||||
expect(await manager.create()).toMatchObject({ ok: false })
|
||||
})
|
||||
|
||||
it('create forwards the requested Agent preset and catalogs the identity the Host returns', async ({ mock, remote }) => {
|
||||
remote.session.create.mockResolvedValue(ok({ sessionId: S2, agentPreset: 'minimal' }))
|
||||
const manager = makeManager(mock, remote)
|
||||
|
||||
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1, agentPreset: 'minimal' })
|
||||
|
||||
expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: 'w1', sessionId: S1, agentPreset: 'minimal' })
|
||||
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2])
|
||||
})
|
||||
|
||||
it.for(['create', 'fork'] as const)('preserves pushed running state when a %s response arrives later', async (operation, { mock, remote }) => {
|
||||
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.create>>>()
|
||||
remote.session[operation].mockImplementation(() => response.promise)
|
||||
|
||||
@@ -9,7 +9,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionCache, {
|
||||
checkpointRecord,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Session Controller under non-owner callers: the Session resource kind it
|
||||
* registers, the Session readers of `session/page` and `session/follow`,
|
||||
* `session/list`, `session/search`, and `session/control` called through a
|
||||
* `global` role and filtered per Session through `remoteAccess.allows()`, the caller recorded
|
||||
* on prompt sources, and `session/projections` admitted per Session through
|
||||
* the Gateway.
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import TypertGatewayService from '@deepseek-ai/dsh-api-gateway'
|
||||
import type { RemoteAccessBinding, RemoteAccessRole } from '@deepseek-ai/dsh-api-remote-access'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { remoteMethods, type PeerId, type PeerScope, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type SessionController from '../src/index.ts'
|
||||
import { ApiSessionList } from '../src/list.ts'
|
||||
import type { SessionControlFrame, SessionRequestId, SessionSearchValue } from '../src/types.ts'
|
||||
import { createSessionTestController } from './test-remote.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface PrincipalMap {
|
||||
/** Test-only non-owner caller shared by the access specs. */
|
||||
'test-guest': { readonly kind: 'test-guest'; readonly guest: string }
|
||||
}
|
||||
}
|
||||
|
||||
const OWNER: Principal = { kind: 'owner' }
|
||||
const GUEST: Principal = { kind: 'test-guest', guest: 'session-controller' }
|
||||
const SHARED = SessionId('shared-session')
|
||||
const PRIVATE = SessionId('private-session')
|
||||
|
||||
/** Calls the Session list and control methods; each item still needs a Session role. */
|
||||
const LISTER: RemoteAccessRole<'global'> = { name: 'test:session-lister', resource: 'global', actions: ['session/list', 'session/control'] }
|
||||
/** Reads one Session: its list row, its control projections, and its stored projections. */
|
||||
const READER: RemoteAccessRole<'session'> = {
|
||||
name: 'test:session-reader', resource: 'session', actions: ['session/list', 'session/control', 'session/projections'],
|
||||
}
|
||||
const LIST_CALLS: RemoteAccessBinding = { role: LISTER, resource: { kind: 'global' } }
|
||||
const READS_SHARED: RemoteAccessBinding = { role: READER, resource: { kind: 'session', id: SHARED } }
|
||||
const SEARCH_CALLS: RemoteAccessBinding = {
|
||||
role: { name: 'test:session-searcher', resource: 'global', actions: ['session/search'] },
|
||||
resource: { kind: 'global' },
|
||||
}
|
||||
const SEARCHES_SHARED: RemoteAccessBinding = {
|
||||
role: { name: 'test:session-search-reader', resource: 'session', actions: ['session/search'] },
|
||||
resource: { kind: 'session', id: SHARED },
|
||||
}
|
||||
|
||||
const owned = new Set<Context>()
|
||||
afterEach(async () => {
|
||||
await Promise.all([...owned].map(ctx => ctx.fiber.dispose()))
|
||||
owned.clear()
|
||||
})
|
||||
|
||||
/**
|
||||
* Register the guest policy.
|
||||
* @returns the bindings the policy answers with; edits apply from the next Remote call.
|
||||
*/
|
||||
function guestPolicy(ctx: Context, ...bindings: RemoteAccessBinding[]): RemoteAccessBinding[] {
|
||||
const held = [...bindings]
|
||||
ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => held })
|
||||
return held
|
||||
}
|
||||
|
||||
/** Invoke `session/<method>` as the guest through the Gateway. */
|
||||
function guestCall(ctx: Context, method: string, args: Readonly<Record<string, unknown>>): Promise<unknown> {
|
||||
const peer: PeerScope = { id: 'session-access-peer' as PeerId, ctx, principal: GUEST, interactions: 'answer', signal: new AbortController().signal, dispose: async () => {} }
|
||||
return ctx.typertGateway.invoke({ peer, namespace: 'session', method, args })
|
||||
}
|
||||
|
||||
async function harness(): Promise<{
|
||||
readonly ctx: Context
|
||||
readonly controller: SessionController
|
||||
readonly shared: Agent
|
||||
readonly hidden: Agent
|
||||
/** The Context a Gateway dispatch of `session/<method>` binds for `principal`. */
|
||||
readonly as: (principal: Principal, method: string) => Context
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
owned.add(ctx)
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const loop = await mountAgentLoopTestHarness(ctx)
|
||||
const shared = await loop.create(SHARED)
|
||||
const hidden = await loop.create(PRIVATE)
|
||||
const controller = createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const as = (principal: Principal, method: string): Context => {
|
||||
const invocation: RemoteInvocation = {
|
||||
request: { namespace: 'session', method, args: {} },
|
||||
service: 'sessionController',
|
||||
peer: { id: 'session-access-peer' as PeerId, ctx, principal, interactions: 'answer', signal: new AbortController().signal, dispose: async () => {} },
|
||||
signal: new AbortController().signal,
|
||||
uplink: <In>(): AsyncIterable<In> => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }),
|
||||
}
|
||||
return ctx.extend({ invocation })
|
||||
}
|
||||
return { ctx, controller, shared, hidden, as }
|
||||
}
|
||||
|
||||
function message(text: string) {
|
||||
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
describe('Session Controller access', () => {
|
||||
it('owns the Session resource kind and its default sessionId field', async () => {
|
||||
const { ctx } = await harness()
|
||||
expect(() => ctx.typert.resources.register('session', { field: 'sessionId' })).toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('reads the Session resource of page and follow requests from the addressed Session', async () => {
|
||||
const { controller } = await harness()
|
||||
for (const method of ['page', 'follow']) {
|
||||
const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session']
|
||||
expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED)
|
||||
// A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule.
|
||||
expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE)
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a global role call session/list and lists only the Sessions a Session role admits', async () => {
|
||||
const { ctx, as } = await harness()
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
const held = guestPolicy(ctx, LIST_CALLS, READS_SHARED)
|
||||
const rows = async (): Promise<SessionId[]> => {
|
||||
const value = await guestCall(ctx, 'list', { _request: {} }) as { readonly items: readonly { readonly sessionId: SessionId }[] }
|
||||
return value.items.map(item => item.sessionId)
|
||||
}
|
||||
await expect(rows()).resolves.toEqual([SHARED])
|
||||
// The global role admits the call, not any row.
|
||||
held.splice(0, held.length, LIST_CALLS)
|
||||
await expect(rows()).resolves.toEqual([])
|
||||
// A Session role alone does not admit the call: the global resource does not inherit from it.
|
||||
held.splice(0, held.length, READS_SHARED)
|
||||
await expect(rows()).rejects.toMatchObject({
|
||||
code: 'remote-access/denied', details: { action: 'session/list', reason: 'no-allow' },
|
||||
})
|
||||
const owner = await as(OWNER, 'list').sessionController.list({}, new AbortController().signal)
|
||||
expect(owner.items.map(item => item.sessionId).sort()).toEqual([PRIVATE, SHARED].sort())
|
||||
})
|
||||
|
||||
it('returns a non-owner only the session/search hits whose Session a Session role admits', async () => {
|
||||
const { ctx, as } = await harness()
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
guestPolicy(ctx, SEARCH_CALLS, SEARCHES_SHARED)
|
||||
const hits: SessionSearchValue = {
|
||||
items: [{ sessionId: PRIVATE, snippet: 'private hit' }, { sessionId: SHARED, snippet: 'shared hit' }],
|
||||
hasMore: true,
|
||||
}
|
||||
vi.spyOn(ApiSessionList.prototype, 'search').mockResolvedValue(hits)
|
||||
await expect(guestCall(ctx, 'search', { request: { query: 'hit' } })).resolves.toEqual({
|
||||
items: [{ sessionId: SHARED, snippet: 'shared hit' }],
|
||||
hasMore: true,
|
||||
})
|
||||
await expect(as(OWNER, 'search').sessionController.search({ query: 'hit' }, new AbortController().signal)).resolves.toEqual(hits)
|
||||
})
|
||||
|
||||
it('filters session/control baselines and projection frames for a non-owner', async () => {
|
||||
const { ctx, shared, hidden, as } = await harness()
|
||||
guestPolicy(ctx, LIST_CALLS, READS_SHARED)
|
||||
const abort = new AbortController()
|
||||
const guest = as(GUEST, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]()
|
||||
const owner = as(OWNER, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]()
|
||||
const baselineSessions = async (iterator: AsyncIterator<SessionControlFrame>): Promise<string[]> => {
|
||||
const next = await iterator.next()
|
||||
if (next.done === true || next.value.type !== 'baseline') throw new Error('control stream opened without a baseline')
|
||||
return Object.keys(next.value.value.projections).sort()
|
||||
}
|
||||
expect(await baselineSessions(guest)).toEqual([SHARED])
|
||||
expect(await baselineSessions(owner)).toEqual([PRIVATE, SHARED].sort())
|
||||
|
||||
hidden.inbox.append('next-turn', message('private work'))
|
||||
shared.inbox.append('next-turn', message('shared work'))
|
||||
const projected = async (iterator: AsyncIterator<SessionControlFrame>): Promise<SessionId> => {
|
||||
for (;;) {
|
||||
const next = await iterator.next()
|
||||
if (next.done === true) throw new Error('control stream ended')
|
||||
if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value.sessionId
|
||||
}
|
||||
}
|
||||
// The owner sees the private Session's frame first; the guest's first Inbox frame is the shared one.
|
||||
expect(await projected(owner)).toBe(PRIVATE)
|
||||
expect(await projected(guest)).toBe(SHARED)
|
||||
abort.abort()
|
||||
await Promise.all([guest.return?.(), owner.return?.()])
|
||||
})
|
||||
|
||||
it('records a non-owner caller on the prompt source and leaves owner sources unchanged', async () => {
|
||||
const { shared, as } = await harness()
|
||||
const signal = new AbortController().signal
|
||||
const prompt = (principal: Principal, requestId: string) => as(principal, 'prompt').sessionController.prompt({
|
||||
sessionId: SHARED,
|
||||
requestId: requestId as SessionRequestId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: `from ${principal.kind}` }],
|
||||
}, signal)
|
||||
await expect(prompt(GUEST, 'guest-1')).resolves.toEqual({ accepted: true })
|
||||
await expect(prompt(OWNER, 'owner-1')).resolves.toEqual({ accepted: true })
|
||||
// The durable Inbox splice records each accepted message with its source.
|
||||
const logged = shared.session.snapshotEvents()
|
||||
.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted.map(item => item.source) : [])
|
||||
expect(logged).toEqual([
|
||||
{ kind: 'user', rpcId: 'guest-1', principal: GUEST },
|
||||
{ kind: 'user', rpcId: 'owner-1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('lets a non-owner read projections only in a Session its Session role names', async () => {
|
||||
const { ctx } = await harness()
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
guestPolicy(ctx, READS_SHARED)
|
||||
const projections = (sessionId: SessionId): Promise<unknown> => guestCall(ctx, 'projections', { request: { sessionId } })
|
||||
await expect(projections(SHARED)).resolves.toMatchObject({ kind: 'sequenced' })
|
||||
await expect(projections(PRIVATE)).rejects.toMatchObject({
|
||||
code: 'remote-access/denied', details: { action: 'session/projections', reason: 'no-allow' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Session creation and adoption rules for Agent preset identity. */
|
||||
/** Session creation, blank-Session reuse, and Agent preset identity through `session.create`. */
|
||||
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -6,12 +6,14 @@ import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createSessionTestRemote } from './test-remote.ts'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts'
|
||||
|
||||
/** Booted contexts and their temp roots, torn down after each test. */
|
||||
const contexts: Context[] = []
|
||||
@@ -25,53 +27,137 @@ function stubAgent(session: Session): Agent {
|
||||
return { id: session.id, session, status: 'idle' } as unknown as Agent
|
||||
}
|
||||
|
||||
function roster(ids: readonly string[]): unknown {
|
||||
const presetOf = (id: string): object => ({
|
||||
id,
|
||||
trust: 'system',
|
||||
})
|
||||
return {
|
||||
defaultId: ids[0],
|
||||
resolve: (id?: string) => {
|
||||
const wanted = id ?? ids[0] ?? ''
|
||||
if (!ids.includes(wanted)) {
|
||||
return Promise.reject(new RemoteError(
|
||||
'agent-preset/not-found',
|
||||
`agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`,
|
||||
{ agentPreset: wanted, available: ids },
|
||||
))
|
||||
}
|
||||
return Promise.resolve(presetOf(wanted))
|
||||
},
|
||||
mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
|
||||
}
|
||||
/** A stored Session the persistence double serves until an Agent resumes it. */
|
||||
interface StoredSession {
|
||||
readonly header: SessionHeader
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
async function harness(presets?: readonly string[]) {
|
||||
interface HarnessOptions {
|
||||
/** Declared presets; the first is the default. Omitted, no roster is composed. */
|
||||
readonly presets?: readonly string[]
|
||||
/** Stored Sessions keyed by id. */
|
||||
readonly stored?: ReadonlyMap<SessionId, StoredSession>
|
||||
/** Stored Sessions whose writer another owner holds. */
|
||||
readonly held?: ReadonlySet<SessionId>
|
||||
/** Stored Sessions another resume in this process publishes while this one waits for the writer. */
|
||||
readonly publishedByOtherResume?: ReadonlySet<SessionId>
|
||||
/** Stored Sessions whose storage fails to open for writing. */
|
||||
readonly unreadable?: ReadonlySet<SessionId>
|
||||
/** Events a resume finds beyond the stored log, as when another Host wrote after the observation. */
|
||||
readonly writtenSinceObservation?: ReadonlyMap<SessionId, readonly SessionEvent[]>
|
||||
/** Live Sessions whose preset switch the roster refuses as already started. */
|
||||
readonly locked?: ReadonlySet<SessionId>
|
||||
/** Declared presets whose activation failed. */
|
||||
readonly broken?: ReadonlySet<string>
|
||||
/** Requested presets whose resolution waits for the paired promise. */
|
||||
readonly resolving?: ReadonlyMap<string, Promise<void>>
|
||||
/** Workspace placement served to `workspaceId` requests. */
|
||||
readonly workspace?: { readonly members: readonly SessionId[]; readonly archived: readonly SessionId[] }
|
||||
}
|
||||
|
||||
const WORKSPACE = 'workspace-1' as WorkspaceId
|
||||
|
||||
async function harness(options: HarnessOptions = {}) {
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-')))
|
||||
tempDirs.push(cwd)
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const presets = options.presets
|
||||
const selections: { sessionId: SessionId; agentPreset: string }[] = []
|
||||
const resumed: SessionId[] = []
|
||||
if (presets !== undefined) {
|
||||
ctx.provide('agentPresets', roster(presets) as never)
|
||||
const known = (id?: string): string => {
|
||||
const wanted = id ?? presets[0] ?? ''
|
||||
if (!presets.includes(wanted)) {
|
||||
throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, {
|
||||
agentPreset: wanted, available: [...presets],
|
||||
})
|
||||
}
|
||||
return wanted
|
||||
}
|
||||
const usable = (id?: string): string => {
|
||||
const wanted = known(id)
|
||||
if (options.broken?.has(wanted) === true) {
|
||||
throw new RemoteError('agent-preset/invalid', 'activation failed', { agentPreset: wanted, reason: 'activation failed' })
|
||||
}
|
||||
return wanted
|
||||
}
|
||||
ctx.provide('agentPresets', {
|
||||
defaultId: presets[0],
|
||||
resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id))
|
||||
.then(() => ({ id: known(id) })),
|
||||
mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })),
|
||||
select: async (agent: Agent, agentPreset: string) => {
|
||||
usable(agentPreset)
|
||||
if (options.locked?.has(agent.id) === true) {
|
||||
throw new RemoteError('agent-preset/locked', 'This session has already started', {
|
||||
sessionId: agent.id, agentPreset,
|
||||
})
|
||||
}
|
||||
selections.push({ sessionId: agent.id, agentPreset })
|
||||
agent.session.append('agent-preset/selected', { agentPreset })
|
||||
return agentPreset
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(_ownerCtx, options) {
|
||||
const session = ctx.sessions.create(
|
||||
options.sessionId,
|
||||
options.meta === undefined ? {} : { meta: options.meta },
|
||||
)
|
||||
const agent = stubAgent(session)
|
||||
;(agent as { ctx?: Context }).ctx = ctx
|
||||
await options.setup?.(ctx, agent)
|
||||
const unregister = await ctx.agents.register(agent)
|
||||
return { agent, dispose: async () => { await unregister() } }
|
||||
const stored = options.stored ?? new Map<SessionId, StoredSession>()
|
||||
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
||||
list: () => Promise.resolve([...stored.values()].map(record => record.header)),
|
||||
inspect: (id: SessionId) => {
|
||||
const record = stored.get(id)
|
||||
return Promise.resolve(record === undefined ? undefined : { meta: record.header, events: [...record.events] })
|
||||
},
|
||||
async resume() {
|
||||
throw new Error('test harness has no persisted sessions')
|
||||
}) as never)
|
||||
const { members, archived } = options.workspace ?? { members: [], archived: [] }
|
||||
const workspace = { id: WORKSPACE, path: cwd, sessionIds: members, attachSession: () => Promise.resolve() }
|
||||
ctx.provide('workspaceRegistry', {
|
||||
get: (id: WorkspaceId) => id === WORKSPACE ? workspace : undefined,
|
||||
list: () => [workspace],
|
||||
archivedSessionIds: archived,
|
||||
} as never)
|
||||
|
||||
/** Run setup on the unpublished Session, then publish it and its Agent, as the Agent factory does. */
|
||||
const publish = async (
|
||||
session: Session,
|
||||
setup: Parameters<AgentFactory['createAgent']>[1]['setup'],
|
||||
): Promise<{ agent: Agent; dispose: () => Promise<void> }> => {
|
||||
const agent = stubAgent(session)
|
||||
;(agent as { ctx?: Context }).ctx = ctx
|
||||
await setup?.(ctx, agent)
|
||||
ctx.effect(function* () {
|
||||
yield ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
}, 'test: published Session')
|
||||
const unregister = await ctx.agents.register(agent)
|
||||
return { agent, dispose: async () => { await unregister() } }
|
||||
}
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(_ownerCtx, create) {
|
||||
const session = ctx.sessions.prepare(create.sessionId, create.meta === undefined ? {} : { meta: create.meta })
|
||||
return publish(session, create.setup)
|
||||
},
|
||||
async resume(_ownerCtx, resume) {
|
||||
const id = resume.resumeSessionId
|
||||
if (options.held?.has(id) === true) {
|
||||
throw Object.assign(new Error(`session "${id}" is written by another owner`), { name: 'SessionAlreadyOwnedError' })
|
||||
}
|
||||
const record = stored.get(id)
|
||||
if (record === undefined || options.unreadable?.has(id) === true) {
|
||||
throw new Error(`test persistence cannot open Session "${id}" for writing`)
|
||||
}
|
||||
if (options.publishedByOtherResume?.has(id) === true) {
|
||||
await publish(ctx.sessions.prepare(id, { meta: record.header, seed: [...record.events] }), undefined)
|
||||
throw Object.assign(new Error(`session "${id}" is written by this process`), { name: 'SessionAlreadyOwnedError' })
|
||||
}
|
||||
resumed.push(id)
|
||||
const session = ctx.sessions.prepare(id, {
|
||||
meta: record.header,
|
||||
seed: [...record.events, ...options.writtenSinceObservation?.get(id) ?? []],
|
||||
})
|
||||
return publish(session, resume.setup)
|
||||
},
|
||||
}
|
||||
ctx.agents.setFactory(factory)
|
||||
@@ -79,13 +165,43 @@ async function harness(presets?: readonly string[]) {
|
||||
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
||||
cwd,
|
||||
})
|
||||
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
||||
if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
|
||||
return { ctx, remote }
|
||||
const presetOf = (id: SessionId): string | null | undefined => {
|
||||
const session = ctx.sessions.get(id)
|
||||
return session === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'agentPreset')
|
||||
}
|
||||
const selectedEvents = (id: SessionId): unknown[] => {
|
||||
const session = ctx.sessions.get(id)
|
||||
return session === undefined ? [] : session.snapshotEvents()
|
||||
.filter(event => event.type === 'agent-preset/selected')
|
||||
.map(event => event.data)
|
||||
}
|
||||
return { ctx, remote, cwd, selections, resumed, presetOf, selectedEvents }
|
||||
}
|
||||
|
||||
function storedHeader(id: SessionId, cwd: string, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, cwd, ...extra }
|
||||
}
|
||||
|
||||
function modelSelection(seq: number): SessionEvent {
|
||||
return { type: 'model/selection', seq: SessionSeq(seq), time: 1, data: { provider: 'test', model: 'test-model' } }
|
||||
}
|
||||
|
||||
function presetSelection(seq: number, agentPreset: string): SessionEvent {
|
||||
return { type: 'agent-preset/selected', seq: SessionSeq(seq), time: 1, data: { agentPreset } }
|
||||
}
|
||||
|
||||
function completedTurn(seq: number): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: SessionSeq(seq), time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: SessionSeq(seq + 1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
describe('session.create Agent preset identity', () => {
|
||||
it('records the requested preset on the Session header', async () => {
|
||||
const { ctx, remote } = await harness(['standard', 'minimal'])
|
||||
const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] })
|
||||
|
||||
const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' })
|
||||
|
||||
@@ -94,7 +210,7 @@ describe('session.create Agent preset identity', () => {
|
||||
})
|
||||
|
||||
it('records the roster default when the caller names no preset', async () => {
|
||||
const { ctx, remote } = await harness(['standard', 'minimal'])
|
||||
const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] })
|
||||
|
||||
await remote.create({ sessionId: SessionId('s2') })
|
||||
|
||||
@@ -102,55 +218,13 @@ describe('session.create Agent preset identity', () => {
|
||||
})
|
||||
|
||||
it('rejects an unknown preset', async () => {
|
||||
const { remote } = await harness(['standard'])
|
||||
const { remote } = await harness({ presets: ['standard'] })
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } })
|
||||
})
|
||||
|
||||
it('refuses to adopt a live Session under a different preset', async () => {
|
||||
const { remote } = await harness(['standard', 'minimal'])
|
||||
await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' })
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' })
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent-preset/conflict',
|
||||
details: {
|
||||
sessionId: 's4',
|
||||
requestedPreset: 'standard',
|
||||
existingPreset: 'minimal',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts a live Session under the preset selected in its log', async () => {
|
||||
const { ctx, remote } = await harness(['standard', 'minimal'])
|
||||
await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' })
|
||||
ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
|
||||
|
||||
const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })
|
||||
const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' })
|
||||
|
||||
expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } })
|
||||
expect(stale).toMatchObject({
|
||||
ok: false,
|
||||
error: { details: { existingPreset: 'minimal' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts a live Session unchanged when the caller names no preset', async () => {
|
||||
const { remote } = await harness(['standard', 'minimal'])
|
||||
await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' })
|
||||
|
||||
await expect(remote.create({ sessionId: SessionId('s5') }))
|
||||
.resolves.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('leaves the header preset-less when no roster is composed', async () => {
|
||||
const { ctx, remote } = await harness()
|
||||
|
||||
@@ -158,6 +232,195 @@ describe('session.create Agent preset identity', () => {
|
||||
|
||||
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.create reuse of a live blank Session', () => {
|
||||
it('recomposes a blank Session under the default when the caller names no preset', async () => {
|
||||
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
||||
await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
||||
|
||||
const reused = await remote.create({ sessionId: SessionId('draft') })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } })
|
||||
expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'standard' }])
|
||||
expect(presetOf(SessionId('draft'))).toBe('standard')
|
||||
})
|
||||
|
||||
it('recomposes a blank Session under the named preset', async () => {
|
||||
const { remote, selections } = await harness({ presets: ['standard', 'minimal'] })
|
||||
await remote.create({ sessionId: SessionId('draft') })
|
||||
|
||||
const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } })
|
||||
expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'minimal' }])
|
||||
})
|
||||
|
||||
it('keeps a blank Session that already runs the requested preset', async () => {
|
||||
const { ctx, remote, selections } = await harness({ presets: ['standard', 'minimal'] })
|
||||
await remote.create({ sessionId: SessionId('draft') })
|
||||
ctx.sessions.get(SessionId('draft'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
|
||||
|
||||
const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } })
|
||||
expect(selections).toEqual([])
|
||||
})
|
||||
|
||||
it('validates the requested preset before touching the blank Session', async () => {
|
||||
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
||||
await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'deleted' })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } })
|
||||
expect(selections).toEqual([])
|
||||
expect(presetOf(SessionId('draft'))).toBe('minimal')
|
||||
})
|
||||
|
||||
it('keeps the blank Session unchanged when the requested preset fails to activate', async () => {
|
||||
const { remote, selections, presetOf } = await harness({
|
||||
presets: ['standard', 'broken'],
|
||||
broken: new Set(['broken']),
|
||||
})
|
||||
await remote.create({ sessionId: SessionId('draft') })
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'broken' })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/invalid' } })
|
||||
expect(selections).toEqual([])
|
||||
expect(presetOf(SessionId('draft'))).toBe('standard')
|
||||
})
|
||||
|
||||
it('creates a new Session instead of reusing one that has started', async () => {
|
||||
const { ctx, remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
||||
await remote.create({ sessionId: SessionId('talk'), agentPreset: 'minimal' })
|
||||
const talk = ctx.sessions.get(SessionId('talk'))
|
||||
talk?.append('turn/start', { turn: 1 })
|
||||
talk?.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const created = await remote.create({ sessionId: SessionId('talk') })
|
||||
|
||||
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
||||
expect(created.value.sessionId).not.toBe('talk')
|
||||
expect(created.value.agentPreset).toBe('standard')
|
||||
expect(selections).toEqual([])
|
||||
expect(presetOf(SessionId('talk'))).toBe('minimal')
|
||||
})
|
||||
|
||||
it('creates a new Session when a turn starts before the preset switch', async () => {
|
||||
const { remote } = await harness({
|
||||
presets: ['standard', 'minimal'],
|
||||
locked: new Set([SessionId('racing')]),
|
||||
})
|
||||
await remote.create({ sessionId: SessionId('racing'), agentPreset: 'minimal' })
|
||||
|
||||
const created = await remote.create({ sessionId: SessionId('racing') })
|
||||
|
||||
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
||||
expect(created.value.sessionId).not.toBe('racing')
|
||||
})
|
||||
|
||||
it('serializes concurrent reuse of one Session in request order', async () => {
|
||||
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
||||
await remote.create({ sessionId: SessionId('draft') })
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }),
|
||||
remote.create({ sessionId: SessionId('draft') }),
|
||||
])
|
||||
|
||||
expect(first).toMatchObject({ ok: true, value: { sessionId: 'draft' } })
|
||||
expect(second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } })
|
||||
expect(selections).toEqual([
|
||||
{ sessionId: 'draft', agentPreset: 'minimal' },
|
||||
{ sessionId: 'draft', agentPreset: 'standard' },
|
||||
])
|
||||
expect(presetOf(SessionId('draft'))).toBe('standard')
|
||||
})
|
||||
|
||||
it('claims one Session in arrival order when an earlier preset resolves later', async () => {
|
||||
const minimal = Promise.withResolvers<undefined>()
|
||||
const { remote, selections, presetOf } = await harness({
|
||||
presets: ['standard', 'minimal'], resolving: new Map([['minimal', minimal.promise]]),
|
||||
})
|
||||
await remote.create({ sessionId: SessionId('draft') })
|
||||
|
||||
const first = remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
||||
const second = remote.create({ sessionId: SessionId('draft') })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
minimal.resolve(undefined)
|
||||
|
||||
expect(await first).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } })
|
||||
expect(await second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } })
|
||||
expect(selections).toEqual([
|
||||
{ sessionId: 'draft', agentPreset: 'minimal' },
|
||||
{ sessionId: 'draft', agentPreset: 'standard' },
|
||||
])
|
||||
expect(presetOf(SessionId('draft'))).toBe('standard')
|
||||
})
|
||||
|
||||
it('shares one creation between concurrent callers of a new identity', async () => {
|
||||
const { ctx, remote } = await harness({ presets: ['standard'] })
|
||||
|
||||
const results = await Promise.all([
|
||||
remote.create({ sessionId: SessionId('fresh') }),
|
||||
remote.create({ sessionId: SessionId('fresh') }),
|
||||
])
|
||||
|
||||
expect(results).toMatchObject([
|
||||
{ ok: true, value: { sessionId: 'fresh' } },
|
||||
{ ok: true, value: { sessionId: 'fresh' } },
|
||||
])
|
||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('refuses a Session in another directory', async () => {
|
||||
const { ctx, remote } = await harness({ presets: ['standard'] })
|
||||
const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-')))
|
||||
tempDirs.push(elsewhere)
|
||||
await remote.create({ sessionId: SessionId('other'), cwd: elsewhere })
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('other') })
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session/conflict', details: { sessionId: 'other', existingCwd: elsewhere } },
|
||||
})
|
||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual(['other'])
|
||||
})
|
||||
|
||||
it('refuses a Session in another directory before resolving the requested preset', async () => {
|
||||
const { remote } = await harness({ presets: ['standard'] })
|
||||
const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-')))
|
||||
tempDirs.push(elsewhere)
|
||||
await remote.create({ sessionId: SessionId('other'), cwd: elsewhere })
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('other'), agentPreset: 'nope' })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'session/conflict', details: { sessionId: 'other' } } })
|
||||
})
|
||||
|
||||
it('leaves a subagent identity to subagent routing', async () => {
|
||||
const { ctx, remote, cwd } = await harness({ presets: ['standard'] })
|
||||
ctx.sessions.create(SessionId('child'), {
|
||||
meta: { cwd, parentSession: SessionId('parent'), origin: 'subagent' },
|
||||
})
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('child') })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
|
||||
})
|
||||
|
||||
it('reuses a preset-less blank Session unchanged when no roster is composed', async () => {
|
||||
const { ctx, remote } = await harness()
|
||||
await remote.create({ sessionId: SessionId('s5') })
|
||||
|
||||
const reused = await remote.create({ sessionId: SessionId('s5') })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 's5' } })
|
||||
expect(ctx.sessions.get(SessionId('s5'))?.header.agentPreset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('explains why a preset-less Session cannot be adopted under one', async () => {
|
||||
const { remote } = await harness()
|
||||
@@ -177,6 +440,235 @@ describe('session.create Agent preset identity', () => {
|
||||
})
|
||||
if (response.ok) throw new Error('unreachable')
|
||||
expect('existingPreset' in response.error.details).toBe(false)
|
||||
expect(response.error.message).toContain('records no agent preset')
|
||||
expect(response.error.message).toBe('the deployment composes no agent preset roster, so session "s7" cannot run preset "standard"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.create reuse of a stored blank Session', () => {
|
||||
it('resumes a blank Session whose recorded preset is no longer declared under the default', async () => {
|
||||
const draft = SessionId('stale-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { remote, resumed, presetOf, selectedEvents } = await harness({
|
||||
presets: ['standard', 'minimal'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]),
|
||||
})
|
||||
|
||||
const reused = await remote.create({ sessionId: draft, cwd })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'stale-draft', agentPreset: 'standard' } })
|
||||
expect(resumed).toEqual(['stale-draft'])
|
||||
expect(presetOf(draft)).toBe('standard')
|
||||
expect(selectedEvents(draft)).toEqual([{ agentPreset: 'standard' }])
|
||||
})
|
||||
|
||||
it('resumes a blank Session under the default even when its previous choice is still declared', async () => {
|
||||
const draft = SessionId('picked-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { remote, presetOf, selectedEvents } = await harness({
|
||||
presets: ['standard', 'minimal'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]),
|
||||
})
|
||||
|
||||
const reused = await remote.create({ sessionId: draft, cwd })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'picked-draft', agentPreset: 'standard' } })
|
||||
expect(presetOf(draft)).toBe('standard')
|
||||
expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }, { agentPreset: 'standard' }])
|
||||
})
|
||||
|
||||
it('resumes a blank Session under its recorded preset when the caller names it', async () => {
|
||||
const draft = SessionId('restored-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { remote, presetOf, selectedEvents } = await harness({
|
||||
presets: ['standard', 'minimal'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]),
|
||||
})
|
||||
|
||||
const reused = await remote.create({ sessionId: draft, cwd, agentPreset: 'minimal' })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'restored-draft', agentPreset: 'minimal' } })
|
||||
expect(presetOf(draft)).toBe('minimal')
|
||||
expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }])
|
||||
})
|
||||
|
||||
it('rejects a named preset that is no longer declared without resuming the Session', async () => {
|
||||
const draft = SessionId('stale-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { ctx, remote, resumed } = await harness({
|
||||
presets: ['standard'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]),
|
||||
})
|
||||
|
||||
const response = await remote.create({ sessionId: draft, cwd, agentPreset: 'liangshen' })
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'agent-preset/not-found', details: { agentPreset: 'liangshen', available: ['standard'] } },
|
||||
})
|
||||
expect(resumed).toEqual([])
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a new Session instead of resuming one that has started', async () => {
|
||||
const talk = SessionId('stored-talk')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { remote, resumed } = await harness({
|
||||
presets: ['standard'],
|
||||
stored: new Map([[talk, { header: storedHeader(talk, cwd, { agentPreset: 'liangshen' }), events: completedTurn(0) }]]),
|
||||
})
|
||||
|
||||
const created = await remote.create({ sessionId: talk, cwd })
|
||||
|
||||
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
||||
expect(created.value.sessionId).not.toBe('stored-talk')
|
||||
expect(created.value.agentPreset).toBe('standard')
|
||||
expect(resumed).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a new Session when another owner holds the blank Session', async () => {
|
||||
const draft = SessionId('held-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { ctx, remote } = await harness({
|
||||
presets: ['standard'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]),
|
||||
held: new Set([draft]),
|
||||
})
|
||||
|
||||
const created = await remote.create({ sessionId: draft, cwd })
|
||||
|
||||
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
||||
expect(created.value.sessionId).not.toBe('held-draft')
|
||||
expect(ctx.sessions.get(draft)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reuses the Agent another resume published while holding the writer', async () => {
|
||||
const draft = SessionId('contended-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { remote, selections, presetOf } = await harness({
|
||||
presets: ['standard', 'minimal'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]),
|
||||
publishedByOtherResume: new Set([draft]),
|
||||
})
|
||||
|
||||
const reused = await remote.create({ sessionId: draft, cwd })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'contended-draft', agentPreset: 'standard' } })
|
||||
expect(selections).toEqual([{ sessionId: 'contended-draft', agentPreset: 'standard' }])
|
||||
expect(presetOf(draft)).toBe('standard')
|
||||
})
|
||||
|
||||
it('reports an unreadable Session instead of creating another one', async () => {
|
||||
const { ctx, remote } = await harness({ presets: ['standard'] })
|
||||
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('unreadable') })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a storage failure instead of creating another Session', async () => {
|
||||
const draft = SessionId('unreadable-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { ctx, remote } = await harness({
|
||||
presets: ['standard'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]),
|
||||
unreadable: new Set([draft]),
|
||||
})
|
||||
|
||||
const response = await remote.create({ sessionId: draft, cwd })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a new Session when the blank Session started after it was observed', async () => {
|
||||
const draft = SessionId('overtaken-draft')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { ctx, remote, resumed } = await harness({
|
||||
presets: ['standard', 'minimal'],
|
||||
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]),
|
||||
writtenSinceObservation: new Map([[draft, completedTurn(1)]]),
|
||||
})
|
||||
|
||||
const created = await remote.create({ sessionId: draft, cwd })
|
||||
|
||||
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
||||
expect(created.value.sessionId).not.toBe('overtaken-draft')
|
||||
expect(resumed).toEqual(['overtaken-draft'])
|
||||
expect(ctx.sessions.get(draft)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a stored Session in another directory', async () => {
|
||||
const other = SessionId('stored-other')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { remote, resumed } = await harness({
|
||||
presets: ['standard'],
|
||||
stored: new Map([[other, { header: storedHeader(other, '/elsewhere'), events: [modelSelection(0)] }]]),
|
||||
})
|
||||
|
||||
const response = await remote.create({ sessionId: other, cwd })
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session/conflict', details: { sessionId: 'stored-other', existingCwd: '/elsewhere' } },
|
||||
})
|
||||
expect(resumed).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a stored subagent identity to subagent routing', async () => {
|
||||
const child = SessionId('stored-child')
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
||||
tempDirs.push(cwd)
|
||||
const { remote, resumed } = await harness({
|
||||
presets: ['standard'],
|
||||
stored: new Map([[child, {
|
||||
header: storedHeader(child, cwd, { parentSession: SessionId('parent'), origin: 'subagent' }),
|
||||
events: [modelSelection(0)],
|
||||
}]]),
|
||||
})
|
||||
|
||||
const response = await remote.create({ sessionId: child, cwd })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
|
||||
expect(resumed).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.create Workspace placement', () => {
|
||||
it('reuses a blank member of the requested Workspace', async () => {
|
||||
const draft = SessionId('member-draft')
|
||||
const { remote } = await harness({ presets: ['standard'], workspace: { members: [draft], archived: [] } })
|
||||
await remote.create({ sessionId: draft })
|
||||
|
||||
const reused = await remote.create({ sessionId: draft, workspaceId: WORKSPACE })
|
||||
|
||||
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'member-draft' } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'outside the Workspace', members: [], archived: [] },
|
||||
{ name: 'archived', members: [SessionId('placed-draft')], archived: [SessionId('placed-draft')] },
|
||||
])('creates a new Session instead of reusing a blank Session $name', async ({ members, archived }) => {
|
||||
const draft = SessionId('placed-draft')
|
||||
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'], workspace: { members, archived } })
|
||||
await remote.create({ sessionId: draft, agentPreset: 'minimal' })
|
||||
|
||||
const created = await remote.create({ sessionId: draft, workspaceId: WORKSPACE })
|
||||
|
||||
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
||||
expect(created.value.sessionId).not.toBe('placed-draft')
|
||||
expect(selections).toEqual([])
|
||||
expect(presetOf(draft)).toBe('minimal')
|
||||
})
|
||||
})
|
||||
@@ -513,6 +513,19 @@ describe('prompt and cancel errors', () => {
|
||||
expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true })
|
||||
})
|
||||
|
||||
it('sends an addressed child continuation under its echo request id and retires a refused echo', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true })
|
||||
await session.open()
|
||||
const accepted = session.beginSubmission({ mode: 'queue', text: '继续', attachments: [] })
|
||||
await session.prompt([{ type: 'text', text: '继续' }], 'queue', undefined, accepted.requestId)
|
||||
expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, ...CHILD }])
|
||||
expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId])
|
||||
|
||||
const refused = session.beginSubmission({ mode: 'queue', text: '附件', attachments: [] })
|
||||
await session.prompt([{ type: 'file', receiptId: 'receipt' as never }], 'queue', undefined, refused.requestId)
|
||||
expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId])
|
||||
})
|
||||
|
||||
it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true })
|
||||
await session.open()
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
remoteErrorOf,
|
||||
type RemoteResult,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
|
||||
import SessionController from '../src/index.ts'
|
||||
import type {
|
||||
ModelCatalog,
|
||||
@@ -243,6 +244,7 @@ function installControllers(
|
||||
ctx.provide('typert', {
|
||||
lookups: { configure: () => dispose },
|
||||
contexts: { configureHost: () => dispose },
|
||||
resources: { register: () => dispose },
|
||||
} as never)
|
||||
}
|
||||
if (ctx.get('agentDefaultModel') === undefined) {
|
||||
@@ -289,6 +291,7 @@ function installControllers(
|
||||
} as never)
|
||||
}
|
||||
installSessionReadTestServices(ctx)
|
||||
if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx)
|
||||
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
|
||||
let controller: SessionController
|
||||
try {
|
||||
|
||||
@@ -104,6 +104,9 @@
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../remote-access/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle(
|
||||
'@deepseek-ai/dsh-api-session-controller',
|
||||
import.meta.dirname,
|
||||
['lib/types/index.js'],
|
||||
{ hostPhase: true },
|
||||
)
|
||||
Reference in new issue
Block a user