refactor(schedule): make absolute times explicit

This commit is contained in:
Tianyi Cui
2026-08-09 16:30:11 +08:00
parent 3d6498e91b
commit b7ec8429a9
109 changed files with 1248 additions and 3219 deletions

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: 9b430fca1c2352334e6428eb37b80726a4e02c02
README.zh.md: c36a88b44348d0054e71ea9d796291b91fadbf20
README.md: 592e831a2e06e144844607cc7d7b71998f7fb11c
README.zh.md: f26cc471b4402c9a1d5fc5029aef4995ee1d1441

View File

@@ -34,6 +34,8 @@ Session titles ride the generic projection pair like every other domain — the
Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable.
`session.prompt` also accepts optional request-local `clientTimeZone` provenance. When present, the Host validates and canonicalizes `UTC` or an IANA Area/Location before Agent entry, rejects invalid input with `invalid-time-zone`, and records the canonical value on that exact `user-rpc` message beside its `rpcId`. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it.
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.

View File

@@ -34,6 +34,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理reasoning元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中也绝不会作为合成行注入客户端可以提示用户作出另一项选择而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable``session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
`session.prompt` 还接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering中途引导不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement而注入上下文审批通知、任务完成、附加快照携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found``session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。

View File

@@ -99,6 +99,25 @@ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
/** Product settings intentionally exposed beside model-provider namespaces. */
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
/** Validate and canonicalize one browser-supplied IANA zone at the wire boundary. */
function canonicalClientTimeZone(value: string): string | undefined {
if (value.length === 0 || value.trim() !== value
|| (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
try {
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value })
.resolvedOptions().timeZone
/* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */
if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined
return canonical
} catch {
// Intl rejects unsupported zone names; the RPC maps that parser rejection below.
return undefined
}
}
/** Read live abort state across awaits without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
@@ -1803,12 +1822,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const { sessionId, mode, content, clientTimeZone } = request.payload
const canonicalTimeZone = clientTimeZone === undefined
? undefined
: canonicalClientTimeZone(clientTimeZone)
if (clientTimeZone !== undefined && canonicalTimeZone === undefined) {
return err(request, {
code: 'invalid-time-zone',
message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
details: { value: clientTimeZone },
})
}
const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId)
if ('refused' in resolved) return resolved.refused
const agent = resolved.agent
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
// Request identity and optional browser zone ride the exact durable user message.
const source: MessageSource = {
kind: 'user',
rpcId: request.rpcId,
...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
}
try {
const message: UserMessage = createUserMessage({ content, source })
if (mode === 'steer') agent.steer(message)

View File

@@ -36,25 +36,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
z.object({
code: z.literal('session-conflict'),
message: z.string(),
details: z.object({
sessionId: z.string(),
requestedCwd: z.string(),
existingCwd: z.string().optional(),
requestedTimeZone: z.string(),
existingTimeZone: z.string().optional(),
}),
}),
z.object({
code: z.literal('invalid-time-zone'),
message: z.string(),
details: z.object({
field: z.union([z.literal('timeZone'), z.literal('clientTimeZone')]),
value: z.union([z.string(), z.null()]),
}),
}),
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }),
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),

View File

@@ -34,14 +34,8 @@ export interface RpcErrorDetailsMap {
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'model-unavailable': { provider: string; model: string }
'session-conflict': {
sessionId: SessionId
requestedCwd: string
existingCwd?: string
requestedTimeZone: string
existingTimeZone?: string
}
'invalid-time-zone': { field: 'timeZone' | 'clientTimeZone'; value: string | null }
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
'invalid-time-zone': { value: string }
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
'workspace-not-found': { workspaceId: string }
'workspace-invalid-path': { path: string }

View File

@@ -95,12 +95,11 @@ export const sessionSearchValueSchema = z.object({
hasMore: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.search'>>>
/** session.create payload; timeZone stays schema-optional so Host omission returns `invalid-time-zone`. */
/** session.create request payload (at most one of workspaceId / cwd). */
export const sessionCreateRequestSchema = z.object({
workspaceId: workspaceIdSchema.optional(),
cwd: z.string().optional(),
sessionId: sessionIdSchema.optional(),
timeZone: z.string().optional(),
}).refine(
payload => payload.workspaceId === undefined || payload.cwd === undefined,
{ message: 'session.create accepts workspaceId or cwd, not both' },
@@ -247,7 +246,7 @@ export const sessionSelectModelValueSchema = z.object({
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })
/** session.prompt payload; clientTimeZone stays schema-optional so Host omission returns `invalid-time-zone`. */
/** session.prompt request payload, including optional browser-local request provenance. */
export const sessionPromptRequestSchema = z.object({
sessionId: sessionIdSchema,
mode: z.union([z.literal('queue'), z.literal('steer')]),

View File

@@ -20,9 +20,10 @@ declare module '@deepseek-ai/dsh-llm' {
* The prompt's rpcId is passed through MessageSource into the `user/message` event
* (the client uses it to reconcile the optimistically
* echoed provisional message with the event stream). kind stays `'user'` — the model face
* carries no transport vocabulary; rpcId is an extra durable-JSON field passed back to the client with the event.
* carries no transport vocabulary; rpcId and the optional Host-validated browser zone are
* durable JSON fields passed back to the client with the event.
*/
'user-rpc': { kind: 'user'; rpcId: RpcId; clientTimeZone: string }
'user-rpc': { kind: 'user'; rpcId: RpcId; clientTimeZone?: string }
}
}
@@ -204,20 +205,12 @@ export interface SessionsApi {
/**
* Creates a real session and its idle agent. At most one of `workspaceId` /
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
* preallocate `sessionId`: retries with the same id, cwd, and canonical time
* zone return the same session, while a different owned identity fails with
* `session-conflict`. A headerless persisted session remains compatible with
* the same cwd but never absorbs the request zone. Workspace
* preallocate `sessionId`: retries with the same id and cwd return the same
* session, while a different cwd fails with `session-conflict`. Workspace
* creation attaches the session after publication; an attach failure
* returns `workspace-attach-failed` with the published session id.
*/
create(request: RpcRequest<{
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
/** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */
timeZone?: string
}>):
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/**
@@ -296,12 +289,16 @@ export interface SessionsApi {
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
/**
* Sends a message to an ordinary session Agent. Browser callers attach their current IANA zone;
* the Host validates, canonicalizes, and records it on that exact user message. Omission remains
* valid for non-browser callers. Session-backed subagents reject with `agent-busy` and use
* `subagent.prompt`.
*/
prompt(request: RpcRequest<{
sessionId: SessionId
mode: 'queue' | 'steer'
content: ContentBlock[]
/** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */
clientTimeZone?: string
}>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>

View File

@@ -31,10 +31,7 @@ const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return {
rpcId: RpcId(`cold-${String(nextRpc++)}`),
payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
}
return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
}
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
@@ -471,6 +468,81 @@ describe('subagent ownership fence', () => {
expect(response.result.ok).toBe(true)
expect(followup).toHaveBeenCalledOnce()
})
it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const alias = 'US/Pacific'
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
.resolvedOptions().timeZone
const zonedRequest = request({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'zoned work' }],
clientTimeZone: alias,
})
await expect(api.sessions.prompt(zonedRequest)).resolves.toMatchObject({
result: { ok: true },
})
expect(followup).toHaveBeenNthCalledWith(1, expect.objectContaining({
source: { kind: 'user', rpcId: zonedRequest.rpcId, clientTimeZone: canonical },
}))
const utcRequest = request({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'UTC work' }],
clientTimeZone: 'UTC',
})
await expect(api.sessions.prompt(utcRequest)).resolves.toMatchObject({
result: { ok: true },
})
expect(followup).toHaveBeenNthCalledWith(2, expect.objectContaining({
source: { kind: 'user', rpcId: utcRequest.rpcId, clientTimeZone: 'UTC' },
}))
const unzonedRequest = request({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'headless work' }],
})
await expect(api.sessions.prompt(unzonedRequest)).resolves.toMatchObject({
result: { ok: true },
})
expect(followup).toHaveBeenNthCalledWith(3, expect.objectContaining({
source: { kind: 'user', rpcId: unzonedRequest.rpcId },
}))
for (const clientTimeZone of ['', ' UTC', 'CST', 'Not/A_Real_Zone']) {
const invalid = await api.sessions.prompt(request({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'invalid zone' }],
clientTimeZone,
}))
expect(invalid.result).toEqual({
ok: false,
error: {
code: 'invalid-time-zone',
message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
details: { value: clientTimeZone },
},
})
}
expect(followup).toHaveBeenCalledTimes(3)
})
})
describe('degenerate composition (no persistence, no factory)', () => {
@@ -513,89 +585,6 @@ describe('degenerate composition (no persistence, no factory)', () => {
})
})
describe('cold Session zone identity', () => {
it('rejects a different requested zone before resuming a persisted identity', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-cold-zone-conflict')
const meta = header('session-cold-zone-conflict', 1000, { timeZone: 'UTC' })
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.create(request({
sessionId,
cwd: '/proj',
timeZone: 'Asia/Shanghai',
}))
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'session-conflict',
details: {
sessionId,
existingCwd: '/proj',
existingTimeZone: 'UTC',
requestedTimeZone: 'Asia/Shanghai',
},
},
})
expect(resume).not.toHaveBeenCalled()
})
it.each([
['a missing zone', undefined, null],
['an invalid zone', 'CST', 'CST'],
] as const)('rejects %s before resuming a cold Session', async (_case, clientTimeZone, detailValue) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-cold-prompt-zone')
const meta = header('session-cold-prompt-zone', 1000, { timeZone: 'UTC' })
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const promptRequest = request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'rejected before resume' }],
clientTimeZone: clientTimeZone ?? 'UTC',
})
if (clientTimeZone === undefined) {
delete (promptRequest.payload as { clientTimeZone?: string }).clientTimeZone
}
const response = await api.sessions.prompt(promptRequest)
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'invalid-time-zone',
details: { field: 'clientTimeZone', value: detailValue },
},
})
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
})
describe('sessions.prompt synchronous rejection', () => {
it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
const ctx = new Context()

View File

@@ -55,7 +55,7 @@ function liveAgent(
id: string,
turns: number,
tail: Tail = 'none',
lineage: { parentSession?: SessionId; origin?: 'subagent'; timeZone?: string } = {},
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
for (let turn = 1; turn <= turns; turn++) {
@@ -90,7 +90,7 @@ const api = (ctx: Context) => createApiProxy(ctx, {
describe('sessions.fork', () => {
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-source', 2, 'none', { timeZone: 'Asia/Shanghai' })
const source = liveAgent(ctx, 'session-source', 2)
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
@@ -100,7 +100,6 @@ describe('sessions.fork', () => {
])
expect(child?.header.parentSession).toBe(source.id)
expect(child?.header.cwd).toBe('/proj')
expect(child?.header.timeZone).toBe('Asia/Shanghai')
await ctx.fiber.dispose()
})
@@ -158,7 +157,6 @@ describe('sessions.fork', () => {
id: sourceId,
createdAt: 1,
cwd: '/proj',
timeZone: 'America/New_York',
parentSession: parentId,
origin: 'subagent',
}
@@ -197,7 +195,6 @@ describe('sessions.fork', () => {
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
parentSession: sourceId,
cwd: '/proj',
timeZone: 'America/New_York',
})
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()

View File

@@ -315,7 +315,6 @@ describe('Web session model selection', () => {
// callable, so the refusal has to live here.
const refused = await api.sessions.prompt(request({
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
clientTimeZone: 'UTC',
}))
expect(refused.result).toMatchObject({
ok: false,

View File

@@ -1,298 +0,0 @@
/**
* Schedule reminder views cross the Host only after persistence proves their
* dispatch prefix. Live append sends raw events; session/flushed replays the
* identical dispatch with a generic sidecar. History independently gates the
* same projection on an identity-matching stored prefix.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
import { ScheduleId } from '@deepseek-ai/dsh-tool-schedule'
interface FlushControl {
handler: () => true | Promise<true>
}
function reminderCreateData(id: string, prompt: string) {
return {
version: 1 as const,
operation: 'create' as const,
schedule: {
id: ScheduleId(id),
kind: 'after' as const,
prompt,
afterSeconds: 1,
scheduledAt: '2026-08-05T12:00:01.000Z',
},
}
}
async function harness(control?: FlushControl): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
if (control !== undefined) ctx.on('session/flush', () => control.handler())
return ctx
}
function appendReminder(
session: Session,
id: string,
prompt: string,
): { create: SessionEvent; dispatch: SessionEvent } {
const scheduleId = ScheduleId(id)
const create = session.append('schedule/change', reminderCreateData(id, prompt))
const dispatch = session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: scheduleId,
})
return { create, dispatch }
}
async function collectEvents(
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
count: number,
abort: AbortController,
): Promise<Extract<MuxFrame, { type: 'session/event' }>[]> {
const events: Extract<MuxFrame, { type: 'session/event' }>[] = []
for await (const envelope of iterable) {
if (envelope.payload.type !== 'session/event') continue
events.push(envelope.payload)
if (events.length >= count) abort.abort()
}
return events
}
describe('commit-aware Schedule live views', () => {
it('takes the max of reverse flush completion and replays each dispatch once', async () => {
const first = Promise.withResolvers<true>()
let calls = 0
const ctx = await harness({
handler: () => ++calls === 1 ? first.promise : true,
})
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const collected = collectEvents(
api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
6,
abort,
)
const session = ctx.sessions.create(SessionId('schedule-live'))
const firstPair = appendReminder(session, 'schedule-1', 'first')
const slow = ctx.sessions.flush(session)
const secondPair = appendReminder(session, 'schedule-2', 'second')
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
first.resolve(true)
await expect(slow).resolves.toBe(true)
const frames = await collected
const raw = frames.filter(frame => frame.view === undefined)
const presented = frames.filter(frame => frame.view?.for === 'event')
expect(raw.map(frame => frame.event.seq)).toEqual([0, 1, 2, 3])
expect(presented.map(frame => frame.event.seq)).toEqual([1, 3])
expect(presented[0]?.event).toBe(firstPair.dispatch)
expect(presented[1]?.event).toBe(secondPair.dispatch)
expect(presented.map(frame => frame.view)).toEqual([
{
for: 'event',
view: {
scheduleId: 'schedule-1', prompt: 'first',
occurrenceAt: '2026-08-05T12:00:01.000Z',
},
},
{
for: 'event',
view: {
scheduleId: 'schedule-2', prompt: 'second',
occurrenceAt: '2026-08-05T12:00:01.000Z',
},
},
])
expect(firstPair.create.seq).toBe(0)
await ctx.fiber.dispose()
})
it('withholds a view after rejection and publishes it on the next successful checkpoint', async () => {
let calls = 0
const ctx = await harness({
handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
})
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const collected = collectEvents(
api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
3,
abort,
)
const session = ctx.sessions.create(SessionId('schedule-retry'))
appendReminder(session, 'schedule-1', 'retry me')
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk unavailable')
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
const frames = await collected
expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1)
expect(frames.at(-1)?.view).toMatchObject({
for: 'event',
})
await ctx.fiber.dispose()
})
})
describe('Schedule history views', () => {
it('presents a resumed ancestor dispatch copied into a fork seed', async () => {
const ctx = await harness()
const scheduleId = ScheduleId('resumed-reminder')
const resumed = ctx.sessions.create(SessionId('schedule-resumed'), {
seed: [{
type: 'schedule/change',
seq: 0,
time: 1,
data: reminderCreateData('resumed-reminder', 'after restart'),
}],
meta: { cwd: '/tmp' },
})
const dispatch = resumed.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: scheduleId,
})
const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork'))
ctx.provide('sessionPersistence', {
readFrom: () => Promise.resolve({ meta: child.header, events: [...child.events] }),
} as never)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history({
rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id },
})
if (!response.result.ok) throw new Error(response.result.error.message)
expect(response.result.value.events.find(entry => entry.event.seq === dispatch.seq)?.view).toEqual({
for: 'event',
view: {
scheduleId,
prompt: 'after restart',
occurrenceAt: '2026-08-05T12:00:01.000Z',
},
})
await ctx.fiber.dispose()
})
it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => {
const ctx = await harness()
const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } })
appendReminder(parent, 'parent-reminder', 'from parent')
const session = ctx.sessions.create(SessionId('schedule-attached'), {
seed: [...parent.events],
meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
})
let readFrom = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
meta: session.header,
events: [...session.events.slice(0, 1)],
})
ctx.provide('sessionPersistence', {
readFrom: () => readFrom(),
} as never)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const history = async () => {
const response = await api.sessions.history({
rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
})
if (!response.result.ok) throw new Error(response.result.error.message)
return response.result.value.events
}
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
readFrom = () => Promise.resolve({
meta: { ...session.header, delegationDepth: 0 },
events: [...session.events.slice(0, 2)],
})
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
for: 'event',
})
readFrom = () => Promise.resolve({
meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
events: [...session.events.slice(0, 2)],
})
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
readFrom = () => Promise.resolve({
meta: { ...session.header, timeZone: 'UTC', delegationDepth: 0 },
events: [...session.events.slice(0, 2)],
})
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
readFrom = () => Promise.reject(new Error('physical read unavailable'))
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
await ctx.fiber.dispose()
})
it('presents every dispatch in detached persisted history', async () => {
const ctx = await harness()
let source: Session | undefined
const owner = await ctx.plugin(Object.assign((inner: Context) => {
source = inner.sessions.create(SessionId('schedule-source'), { meta: { cwd: '/tmp' } })
}, { inject: ['sessions'] }))
if (source === undefined) throw new Error('session owner did not publish its session')
appendReminder(source, 'schedule-1', 'cold reminder')
const meta = source.header
const events = [...source.events]
await owner.dispose()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
readFrom: () => Promise.resolve({ meta, events }),
} as never)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history({
rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
})
if (!response.result.ok) throw new Error(response.result.error.message)
expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({
for: 'event',
})
await ctx.fiber.dispose()
})
it('withholds a detached view that exists only in a logical inspection', async () => {
const ctx = await harness()
let source: Session | undefined
const owner = await ctx.plugin(Object.assign((inner: Context) => {
source = inner.sessions.create(SessionId('schedule-logical-only'), { meta: { cwd: '/tmp' } })
}, { inject: ['sessions'] }))
if (source === undefined) throw new Error('session owner did not publish its session')
appendReminder(source, 'schedule-logical', 'not physically committed')
const meta = source.header
const events = [...source.events]
await owner.dispose()
let physicalEvents = events.slice(0, 1)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
readFrom: () => Promise.resolve({ meta, events: physicalEvents }),
} as never)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const history = async () => {
const response = await api.sessions.history({
rpcId: RpcId('schedule-logical-only-history'), payload: { sessionId: meta.id },
})
if (!response.result.ok) throw new Error(response.result.error.message)
return response.result.value.events
}
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
physicalEvents = events
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ for: 'event' })
await ctx.fiber.dispose()
})
})

View File

@@ -22,10 +22,7 @@ import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/help
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return {
rpcId: RpcId(`workspace-${String(nextRpc++)}`),
payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
}
return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
}
function expectOk<T>(response: RpcResponse<T>): T {
@@ -362,157 +359,6 @@ describe('session creation and Workspace membership', () => {
expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
})
it('canonicalizes the immutable Session zone and rejects identity conflicts', async () => {
const { api, ctx, workspaceRoot } = await harness()
const sessionId = SessionId('session-zone-identity')
const alias = 'US/Eastern'
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
.resolvedOptions().timeZone
expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: alias })))
expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe(canonical)
expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: canonical })))
const conflict = await api.sessions.create(request({
sessionId,
cwd: workspaceRoot,
timeZone: 'Asia/Shanghai',
}))
expect(conflict.result).toMatchObject({
ok: false,
error: {
code: 'session-conflict',
details: {
sessionId,
requestedCwd: workspaceRoot,
requestedTimeZone: 'Asia/Shanghai',
existingTimeZone: canonical,
},
},
})
})
it('keeps a live headerless Session compatible without absorbing a request zone', async () => {
const { api, ctx, workspaceRoot } = await harness()
const session = ctx.sessions.create(SessionId('session-zone-headerless'), {
meta: { cwd: workspaceRoot },
})
ctx.agents.register(stubAgent(session))
expectOk(await api.sessions.create(request({
sessionId: session.id,
cwd: workspaceRoot,
timeZone: 'Asia/Shanghai',
})))
expect(session.header.timeZone).toBeUndefined()
})
it('serializes different-zone creates so the first immutable identity wins', async () => {
const { api, ctx, workspaceRoot } = await harness()
const sessionId = SessionId('session-zone-race')
const first = api.sessions.create(request({
sessionId,
cwd: workspaceRoot,
timeZone: 'UTC',
}))
const second = api.sessions.create(request({
sessionId,
cwd: workspaceRoot,
timeZone: 'Asia/Shanghai',
}))
const [firstResult, secondResult] = await Promise.all([first, second])
expect(firstResult.result).toMatchObject({ ok: true, value: { sessionId } })
expect(secondResult.result).toMatchObject({
ok: false,
error: { code: 'session-conflict', details: { existingTimeZone: 'UTC' } },
})
expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe('UTC')
})
it.each([
[undefined, null],
['', ''],
[' UTC', ' UTC'],
['CST', 'CST'],
['GMT', 'GMT'],
['+08:00', '+08:00'],
['Not/A_Real_Zone', 'Not/A_Real_Zone'],
] as const)('rejects invalid Session zone input %j before Agent creation', async (timeZone, value) => {
const { api, ctx } = await harness()
const invalidRequest = request({})
Object.assign(invalidRequest.payload, { timeZone })
const response = await api.sessions.create(invalidRequest)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'invalid-time-zone', details: { field: 'timeZone', value } },
})
expect(ctx.agents.list()).toHaveLength(0)
})
it('binds each canonical client zone to its own queued or steering message source', async () => {
const { api, ctx } = await harness()
const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error('created Agent missing')
const followup = vi.spyOn(agent, 'followup')
const steer = vi.spyOn(agent, 'steer')
const alias = 'US/Eastern'
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
.resolvedOptions().timeZone
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'queue' }],
clientTimeZone: alias,
})))
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'steer',
content: [{ type: 'text', text: 'steer' }],
clientTimeZone: 'Asia/Shanghai',
})))
expect(followup.mock.calls[0]?.[0].source).toMatchObject({
kind: 'user',
clientTimeZone: canonical,
})
expect(steer.mock.calls[0]?.[0].source).toMatchObject({
kind: 'user',
clientTimeZone: 'Asia/Shanghai',
})
})
it.each([undefined, '', 'CST', 'Not/A_Real_Zone'] as const)(
'rejects invalid prompt zone input %j before delivery',
async (clientTimeZone) => {
const { api, ctx } = await harness()
const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error('created Agent missing')
const followup = vi.spyOn(agent, 'followup')
const invalidRequest = request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'rejected' }],
})
Object.assign(invalidRequest.payload, { clientTimeZone })
const response = await api.sessions.prompt(invalidRequest)
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'invalid-time-zone',
details: { field: 'clientTimeZone', value: clientTimeZone ?? null },
},
})
expect(followup).not.toHaveBeenCalled()
},
)
})
describe('Host Workspace increments', () => {

View File

@@ -310,7 +310,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
ok: true,
value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false },
})
expect((await c.sessions.create({ timeZone: 'UTC' })).result.ok).toBe(true)
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
const selected = await c.sessions.selectModel({
sessionId: 's' as never,
@@ -330,12 +330,7 @@ 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' }],
clientTimeZone: 'UTC',
})).result.ok).toBe(true)
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.updateQueue({
sessionId: 's' as never,
itemId: 'item-1' as never,

View File

@@ -59,7 +59,8 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b', requestedTimeZone: 'UTC' } }).code).toBe('session-conflict')
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict')
expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone')
expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed')
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
@@ -245,8 +246,17 @@ describe('sessions domain schemas', () => {
}],
failures: [],
})).toThrow()
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
const prompt = sessionPromptRequestSchema.parse({
sessionId: 's1',
mode: 'queue',
content: [{ type: 'text', text: 'hi' }],
clientTimeZone: 'Asia/Shanghai',
})
expect(prompt.mode).toBe('queue')
expect(prompt.clientTimeZone).toBe('Asia/Shanghai')
expect(sessionPromptRequestSchema.parse({
sessionId: 's1', mode: 'queue', content: [],
}).clientTimeZone).toBeUndefined()
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
// The command slot appears only when the prompt dispatched a slash command.