diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 055804b569..70618612a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-gui-layering-and-rpc-protocol.md: 63db4786adcc007d09b7a58824a59f4d1e1e8be1 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: b3037ceb8c172925581d2862ea675e53a7f8c54e +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 63db4786ad..b9718da472 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -165,7 +165,7 @@ One example row (the table structure is the reading key): |---|---|---|---| | `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index | -The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`. +The remaining methods (`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`. ### Frames (server→client, named unions) @@ -187,7 +187,7 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame` - **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it). - **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only. - **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears. -- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. +- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.) ## The client carrier: the AbstractApiClient class family (`fetch/client.ts`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index b3037ceb8c..2add148054 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -163,7 +163,7 @@ export type ResponseValue = |---|---|---|---| | `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 | -其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。 +其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。 ### 帧(server→client,具名 union) @@ -185,7 +185,7 @@ export type ResponseValue = - **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。 - **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。 - **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。 -- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。 +- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。) ## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..6f22ce2466 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 -README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 +README.md: b5f80dcb3a077a411db3b721737a9c16b56fcecf +README.zh.md: 1c3c7486f7d500f8c2d36028d47f29b112d9b5ae diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..b5f80dcb3a 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. +Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..1c3c7486f7 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,7 @@ `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 +会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 8cbdb91cf4..8b4a895774 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f178bfefd0..8ed681e239 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -36,6 +36,8 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. +// Type-only edge: resolves `ctx.get('sessionTitle')` for the rename impl. +import type {} from '@deepseek-ai/dsh-session-title' import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' import type { CallId } from '@deepseek-ai/dsh-llm/brand' @@ -1049,6 +1051,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, + async rename(request) { + const { sessionId, title } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const titles = ctx.get('sessionTitle') + if (titles === undefined) { + return err(request, { code: 'internal', message: 'session-title service is absent: this deployment does not mount @deepseek-ai/dsh-session-title in its composition (cordis.yml or explicit assembly)', details: {} }) + } + try { + const accepted = titles.rename(found.agent.session, title) + return ok(request, { title: accepted.title, seq: accepted.eventSeq }) + } catch (error: unknown) { + return err(request, { + code: 'title-invalid', + message: `rename rejected for session "${sessionId}": ${String(error)}`, + details: { sessionId }, + }) + } + }, + async prompt(request) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 5394691248..8b26b1fc2a 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -23,6 +23,7 @@ export interface RpcMethodMap { 'session.history': SessionsApi['history'] 'session.models': SessionsApi['models'] 'session.selectModel': SessionsApi['selectModel'] + 'session.rename': SessionsApi['rename'] 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 58d3a17126..db5435a757 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -49,6 +49,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), + z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index fd99d015ee..8daf898693 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -48,6 +48,7 @@ export interface RpcErrorDetailsMap { 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} + 'title-invalid': { sessionId: SessionId } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81c42b8a56..29dd88c89b 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -73,6 +73,18 @@ export const sessionCreateValueSchema = z.object({ sessionId: sessionIdSchema, }) satisfies z.ZodType>> +/** session.rename request payload (raw title; host-side normalization decides acceptance). */ +export const sessionRenameRequestSchema = z.object({ + sessionId: sessionIdSchema, + title: z.string(), +}) satisfies z.ZodType>> + +/** session.rename response value (the normalized accepted title and its event seq). */ +export const sessionRenameValueSchema = z.object({ + title: z.string().min(1), + seq: z.number().int().nonnegative(), +}) satisfies z.ZodType>> + /** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */ export const sessionHistoryRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index f952c7c542..3a824c8261 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -208,6 +208,16 @@ export interface SessionsApi { }>): Promise> + /** + * Renames a session: appends a `session/title` event with the `user` + * source, which pins the title against automatic regeneration. The + * normalized accepted title and the title event's seq return so the caller + * can settle its projection cell without waiting for the push frame. A + * title that normalizes to empty fails with `title-invalid`. + */ + rename(request: RpcRequest<{ sessionId: SessionId; title: string }>): + Promise> + /** * Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. * A prompt whose content is exactly one text block starting with '/' is a slash command: the host diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 8bcc6f7c3e..5940775499 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,6 +20,7 @@ import { import { sessionCancelValueSchema, sessionCreateValueSchema, + sessionRenameValueSchema, sessionHistoryValueSchema, sessionListValueSchema, sessionModelsValueSchema, @@ -66,6 +67,7 @@ export interface IApiClient { history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise>> selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> + rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } @@ -116,6 +118,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.history', payload, signal), models: (payload, signal) => this.callUnary('session.models', payload, signal), selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal), + rename: (payload, signal) => this.callUnary('session.rename', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e1340aad5b..c14e680119 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' import { sessionCancelRequestSchema, sessionCreateRequestSchema, + sessionRenameRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, sessionModelsRequestSchema, @@ -68,6 +69,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) }, 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, + 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts new file mode 100644 index 0000000000..5bdac4fb11 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -0,0 +1,110 @@ +/** + * sessions.rename delegation through the composed SessionTitleService. The + * agent factory is a structural stub whose createAgent forwards seed/meta into + * the real SessionStore, and whose resume never runs (every source here is + * already attached). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionTitleService from '@deepseek-ai/dsh-session-title' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (id: string): SessionId => id as SessionId + +let nextRpc = 1 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload } +} + +async function composed(withTitles = true): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + if (withTitles) { + await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 }) + } + // Store-backed structural factory: create builds the session with the + // forwarded seed/meta (the store validates the balanced prefix) and + // registers an idle agent stub over it. + ctx.agents.setFactory({ + createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise => { + const session = ctx.sessions.create(options.sessionId, { + ...options.seed === undefined ? {} : { seed: [...options.seed] }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent + ctx.agents.register(agent) + return Promise.resolve({ agent, dispose: () => Promise.resolve() }) + }, + resume: () => Promise.reject(new Error('resume must not run: every source is attached')), + }) + return ctx +} + +/** Register one live agent whose log holds `turns` completed turns. */ +function liveAgent(ctx: Context, id: string, turns: number): Session { + const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } }) + for (let turn = 1; turn <= turns; turn++) { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `prompt ${String(turn)}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + return session +} + +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + +describe('sessions.rename', () => { + it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-rename', 1) + + const renamed = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' new name ' })) + expect(renamed.result.ok).toBe(true) + if (!renamed.result.ok) return + expect(renamed.result.value.title).toBe('new name') + const event = source.events.findLast(item => item.type === 'session/title') + expect(event?.seq).toBe(renamed.result.value.seq) + expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) + }) + + it('maps an empty-normalizing title to title-invalid', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-rename-bad', 1) + + const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ' })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) { + expect(response.result.error).toMatchObject({ + code: 'title-invalid', + details: { sessionId: source.id }, + }) + } + }) + + it('answers internal when the composition mounts no session-title service', async () => { + const ctx = await composed(false) + const source = liveAgent(ctx, 'session-no-titles', 1) + + const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) { + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toMatch(/session-title service is absent/) + } + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d807557d00..4f56f17dfd 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -46,6 +46,7 @@ function scriptedApi(overrides: { selectModel: r => ok(r, { selected: { provider: r.payload.provider, model: r.payload.model }, }), + rename: r => ok(r, { title: 'renamed', seq: 0 }), prompt: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d7528072f7..1e975d6008 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -67,6 +67,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, } }, + async rename(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } } + }, async prompt(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, @@ -224,6 +227,8 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }, }, }) + const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' }) + expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } }) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 7e1e83e39b..908e2cb546 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../session-projection/session-projection-cache" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ea5bea26..8c728eac97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2890,6 +2890,9 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session-projection/session-projection-cache + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill