Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md
#	.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md
#	.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md
#	docs/user/develop/basic/index.i18n.yaml
#	docs/user/develop/basic/index.zh.md
#	docs/user/guide/config.i18n.yaml
#	docs/user/guide/config.zh.md
#	docs/user/guide/providers.i18n.yaml
#	docs/user/guide/providers.zh.md
#	packages/bundle/base/README.i18n.yaml
#	packages/bundle/base/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.zh.md
This commit is contained in:
xjt
2026-08-12 12:59:38 +08:00
200 changed files with 3714 additions and 1718 deletions
+2 -2
View File
@@ -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: 541ebdb7a6802286b9b698575136534486387a8c
README.zh.md: 94efce64edaf9983cc27285f7a61dd3252573c6e
README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5
README.zh.md: 54fcccc3fef46e717aaf05e3a7ace732a0f4b74a
+1 -1
View File
@@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history.
Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
+1 -1
View File
@@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found``workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
+28 -2
View File
@@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import {
@@ -2758,6 +2758,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return ok(request, { deleted: true as const })
},
async insertBefore(request) {
const { workspaceId, beforeWorkspaceId } = request.payload
try {
const workspaceIds = await ctx.workspace.insertBefore(
brandWorkspaceId(workspaceId),
beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId),
)
return ok(request, { workspaceIds: [...workspaceIds] })
} catch (error: unknown) {
if (!(error instanceof WorkspaceOrderInvalidError)) throw error
return workspaceNotFound(request, error.workspaceId)
}
},
async insertSessionBefore(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
@@ -3412,9 +3426,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
host(_request, signal) {
const queue = new FrameQueue<RpcRequest<HostFrame>>()
const committedWorkspaces = ctx.workspace.list()
const committedWorkspaceIds = new Set(
ctx.workspace.list().map(workspace => String(workspace.id)),
committedWorkspaces.map(workspace => String(workspace.id)),
)
let committedWorkspaceOrder = committedWorkspaces.map(workspace => workspace.id)
// Frame-dedup baseline, same posture as committedWorkspaceIds: the
// stream opens against the current set; workspace.list re-baselines
// reconnecting clients, so only later changes need frames.
@@ -3445,6 +3461,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (change.table === '') {
if (change.operation !== 'put') return
const state = workspaceDomainState.parse(change.value)
const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length
&& state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId)))
&& state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index])
for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue
const workspace = ctx.workspace.get(workspaceId)
@@ -3454,6 +3473,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
committedWorkspaceIds.add(workspaceId)
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
}
committedWorkspaceOrder = [...state.workspaceIds]
if (orderChanged) {
queue.push(frame({
type: 'host/workspace-order-changed',
workspaceIds: [...state.workspaceIds],
}))
}
if (state.archivedSessionIds.length !== archivedSessionIds.length
|| state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) {
archivedSessionIds = state.archivedSessionIds
@@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }),
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
// args stays wide, the same posture as session/projection's value: the frame
// arrives from JSON.parse, so every element is already a JSON value, and the
+3 -1
View File
@@ -119,7 +119,8 @@ export type MuxFrame =
* workspace mutation (create/attach/order change — the client upserts, while
* `workspace.list` provides the reconnect baseline); workspace-removed is the
* committed registration-deletion increment and never implies directory or
* session-log deletion; archived-sessions-changed pushes the full registry
* session-log deletion; workspace-order-changed pushes the complete durable
* registry order after a reorder; archived-sessions-changed pushes the full registry
* archive set after every durable change (same full-snapshot posture as
* workspace-changed — `workspace.list` re-baselines it on reconnect).
*/
@@ -138,6 +139,7 @@ export type HostFrame =
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
| { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] }
| { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
/**
* One allowlisted host cordis event forwarded verbatim. The allowlist is
@@ -47,6 +47,7 @@ export interface RpcMethodMap {
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
'workspace.delete': WorkspaceApi['delete']
'workspace.insertBefore': WorkspaceApi['insertBefore']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'workspace.archiveSession': WorkspaceApi['archiveSession']
'skill.list': SkillsApi['list']
@@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({
deleted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
/** workspace.insertBefore request payload (anchor omitted = append to end). */
export const workspaceInsertBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,
beforeWorkspaceId: workspaceIdSchema.optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertBefore'>>>
/** workspace.insertBefore response value: the complete durable display order. */
export const workspaceInsertBeforeValueSchema = z.object({
workspaceIds: z.array(workspaceIdSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertBefore'>>>
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
export const workspaceInsertSessionBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,
@@ -73,6 +73,15 @@ export interface WorkspaceApi {
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
Promise<RpcResponse<{ deleted: true }>>
/**
* Moves one Workspace within the registry display order,
* DOM-insertBefore-like. An omitted anchor appends to the end.
*/
insertBefore(request: RpcRequest<{
workspaceId: WorkspaceId
beforeWorkspaceId?: WorkspaceId
}>): Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>>
/**
* Moves an accounted session within its workspace's manual order,
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
@@ -35,6 +35,7 @@ import {
workspaceArchiveSessionValueSchema,
workspaceCreateValueSchema,
workspaceDeleteValueSchema,
workspaceInsertBeforeValueSchema,
workspaceInsertSessionBeforeValueSchema,
workspaceListValueSchema,
workspaceRenameValueSchema,
@@ -116,6 +117,7 @@ export interface IApiClient {
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertBefore'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
}
@@ -193,6 +195,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertBefore': workspaceInsertBeforeValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'workspace.archiveSession': workspaceArchiveSessionValueSchema,
'skill.list': skillListValueSchema,
@@ -445,6 +448,7 @@ export abstract class AbstractApiClient implements IApiClient {
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal),
}
@@ -38,6 +38,7 @@ import {
workspaceArchiveSessionRequestSchema,
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
workspaceInsertBeforeRequestSchema,
workspaceInsertSessionBeforeRequestSchema,
workspaceListRequestSchema,
workspaceRenameRequestSchema,
@@ -112,6 +113,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
@@ -319,6 +319,50 @@ describe('workspace.create', () => {
})
})
describe('workspace.insertBefore', () => {
it('commits the complete order, streams one order frame, and maps unknown ids', async () => {
const { api, ctx, root } = await harness()
const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace
const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace
const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace
const abort = new AbortController()
const listWorkspaces = vi.spyOn(ctx.workspace, 'list')
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
expect(listWorkspaces).toHaveBeenCalledTimes(1)
const changed = nextHostFrame(stream)
const reordered = expectOk(await api.workspace.insertBefore(request({
workspaceId: first.workspaceId,
beforeWorkspaceId: second.workspaceId,
})))
expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId])
expect(await changed).toMatchObject({
payload: {
type: 'host/workspace-order-changed',
workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId],
},
})
expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId))
.toEqual(reordered.workspaceIds)
const missingSource = await api.workspace.insertBefore(request({
workspaceId: 'missing' as WorkspaceId,
}))
expect(missingSource.result).toMatchObject({
ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } },
})
const missingAnchor = await api.workspace.insertBefore(request({
workspaceId: first.workspaceId,
beforeWorkspaceId: 'missing-anchor' as WorkspaceId,
}))
expect(missingAnchor.result).toMatchObject({
ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } },
})
abort.abort()
})
})
describe('session creation and Workspace membership', () => {
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
const { api, ctx, root } = await harness()
@@ -85,6 +85,7 @@ function scriptedApi(overrides: {
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
delete: r => ok(r, { deleted: true as const }),
insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }),
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
},
@@ -218,7 +219,7 @@ describe('unary round trip', () => {
expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } })
})
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
it('routes workspace rename, delete, and ordering through the wire', async () => {
const api = scriptedApi()
const c = client(api)
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
@@ -227,6 +228,11 @@ describe('unary round trip', () => {
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
const deleted = await c.workspace.delete({ workspaceId: 'w1' as never })
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
const workspaceOrder = await c.workspace.insertBefore({
workspaceId: 'w1' as never,
beforeWorkspaceId: 'w2' as never,
})
expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } })
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
expect(anchored.result.ok).toBe(true)
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
@@ -179,6 +179,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async delete(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
},
async insertBefore(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } }
},
async insertSessionBefore(request) {
return {
rpcId: request.rpcId,
@@ -23,6 +23,7 @@ import {
workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema,
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema,
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
workspaceListRequestSchema, workspaceListValueSchema,
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
@@ -394,6 +395,17 @@ describe('workspace domain schemas', () => {
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
})
it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => {
expect(workspaceInsertBeforeRequestSchema.parse({
workspaceId: 'w1', beforeWorkspaceId: 'w2',
}).beforeWorkspaceId).toBe('w2')
expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId)
.toBeUndefined()
expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow()
expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds)
.toEqual(['w2', 'w1'])
})
})
describe('skills domain schemas', () => {