mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'upstream/master' into feat/produced-files-folder
This commit is contained in:
@@ -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: 8ade64849193df61a48effaea5739907fda901bf
|
||||
README.zh.md: 5634100ff2a34f507f3e922729a6781ee5c508a5
|
||||
README.md: 541ebdb7a6802286b9b698575136534486387a8c
|
||||
README.zh.md: 595ef03c24873272fa78ad67289b264458083614
|
||||
|
||||
@@ -36,6 +36,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. 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` and `subagent.prompt` accept 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -36,6 +36,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
|
||||
|
||||
`session.prompt` 和 `subagent.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 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
|
||||
|
||||
@@ -247,6 +247,25 @@ function referencedImage(events: readonly SessionEvent[], attachmentId: string):
|
||||
*/
|
||||
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE])
|
||||
|
||||
/** 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
|
||||
@@ -2333,12 +2352,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 }),
|
||||
}
|
||||
const hasImage = content.some(part => part.type === 'image')
|
||||
const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
|
||||
try {
|
||||
@@ -2595,7 +2628,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
async prompt(request, signal) {
|
||||
const { parentSessionId, childSessionId, content } = request.payload
|
||||
const { parentSessionId, childSessionId, 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 parent = ctx.agents.get(parentSessionId)
|
||||
if (parent === undefined) {
|
||||
return err(request, {
|
||||
@@ -2610,7 +2653,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (verified.error !== undefined) return err(request, verified.error)
|
||||
try {
|
||||
const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
|
||||
source: { kind: 'user', rpcId: request.rpcId },
|
||||
source: {
|
||||
kind: 'user',
|
||||
rpcId: request.rpcId,
|
||||
...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return ok(request, { messageId })
|
||||
|
||||
@@ -37,6 +37,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
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() }) }),
|
||||
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() }) }),
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface RpcErrorDetailsMap {
|
||||
'session-not-found': { sessionId: SessionId }
|
||||
'model-unavailable': { provider: string; model: string }
|
||||
'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 }
|
||||
|
||||
@@ -265,11 +265,12 @@ export const promptContentPartSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }),
|
||||
])
|
||||
|
||||
/** session.prompt request payload. */
|
||||
/** 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')]),
|
||||
content: z.array(promptContentPartSchema),
|
||||
clientTimeZone: z.string().optional(),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
|
||||
|
||||
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
|
||||
|
||||
@@ -21,9 +21,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 }
|
||||
'user-rpc': { kind: 'user'; rpcId: RpcId; clientTimeZone?: string }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +309,19 @@ export interface SessionsApi {
|
||||
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/** Sends text and temporary image bytes after durable host admission. Session-backed subagents reject with `agent-busy`. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>):
|
||||
/**
|
||||
* Sends text and temporary image bytes to an ordinary session Agent after durable host admission.
|
||||
* 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: PromptContentPart[]
|
||||
clientTimeZone?: string
|
||||
}>):
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
/** Reads one durable image after proving that this session's log references its id. */
|
||||
|
||||
@@ -67,6 +67,7 @@ export const subagentPromptRequestSchema = z.object({
|
||||
childSessionId: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
content: z.array(contentBlockSchema),
|
||||
clientTimeZone: z.string().optional(),
|
||||
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
|
||||
|
||||
/** subagent.interrupt request payload. */
|
||||
|
||||
@@ -92,10 +92,15 @@ export interface SubagentsApi {
|
||||
* Delivers human content to a continuable child through the exact live
|
||||
* parent's continuation owner. Success identifies the message accepted by
|
||||
* the child's FIFO inbox; later execution is independent of this request.
|
||||
* Optional browser-zone provenance is validated and logged on that message.
|
||||
*/
|
||||
prompt(
|
||||
request: RpcRequest<
|
||||
Extract<SubagentAddress, { mode: 'continuable' }> & { content: ContentBlock[] }
|
||||
Extract<SubagentAddress, { mode: 'continuable' }> & {
|
||||
content: ContentBlock[]
|
||||
/** Optional browser zone sampled for this exact human prompt. */
|
||||
clientTimeZone?: string
|
||||
}
|
||||
>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentPromptReceipt>>
|
||||
|
||||
@@ -468,6 +468,80 @@ 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',
|
||||
})
|
||||
|
||||
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)', () => {
|
||||
|
||||
@@ -50,7 +50,10 @@ function bench(options: {
|
||||
_parent: unknown,
|
||||
_childId: SessionId,
|
||||
_content: unknown,
|
||||
_delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
|
||||
_delivery: {
|
||||
source: { kind: string; rpcId: RpcId; clientTimeZone?: string }
|
||||
signal: AbortSignal
|
||||
},
|
||||
) => options.followupError === undefined
|
||||
? Promise.resolve('message-1')
|
||||
: Promise.reject(options.followupError))
|
||||
@@ -270,6 +273,43 @@ describe('subagent gateway', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('canonicalizes browser-zone provenance before delivering a child prompt', async () => {
|
||||
const { api, parent, followup } = bench()
|
||||
const alias = 'US/Pacific'
|
||||
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
|
||||
.resolvedOptions().timeZone
|
||||
const content = [{ type: 'text' as const, text: 'continue locally' }]
|
||||
const signal = new AbortController().signal
|
||||
await expect(api.subagents.prompt(request({
|
||||
parentSessionId: PARENT,
|
||||
childSessionId: CHILD,
|
||||
mode: 'continuable',
|
||||
content,
|
||||
clientTimeZone: alias,
|
||||
}), signal)).resolves.toMatchObject({ result: { ok: true } })
|
||||
expect(followup).toHaveBeenCalledWith(parent, CHILD, content, {
|
||||
source: { kind: 'user', rpcId: RpcId('subagent-rpc'), clientTimeZone: canonical },
|
||||
signal,
|
||||
})
|
||||
|
||||
const invalid = await api.subagents.prompt(request({
|
||||
parentSessionId: PARENT,
|
||||
childSessionId: CHILD,
|
||||
mode: 'continuable',
|
||||
content,
|
||||
clientTimeZone: 'Not/A_Real_Zone',
|
||||
}), signal)
|
||||
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: 'Not/A_Real_Zone' },
|
||||
},
|
||||
})
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
|
||||
const absent = bench({ parentLive: false })
|
||||
expect((await absent.api.subagents.prompt(request({
|
||||
|
||||
@@ -39,6 +39,7 @@ import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../s
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
import { goalEditRequestSchema } from '../src/api/goals.schema.ts'
|
||||
import { subagentPromptRequestSchema } from '../src/api/subagents.schema.ts'
|
||||
|
||||
describe('RpcId', () => {
|
||||
it('brands a raw string at zero runtime cost', () => {
|
||||
@@ -63,6 +64,7 @@ describe('rpcErrorSchema', () => {
|
||||
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' } }).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')
|
||||
@@ -248,8 +250,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.
|
||||
@@ -275,6 +286,24 @@ describe('sessions domain schemas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent domain schemas', () => {
|
||||
it('carries optional request-local browser-zone provenance on prompts', () => {
|
||||
expect(subagentPromptRequestSchema.parse({
|
||||
parentSessionId: 'parent',
|
||||
childSessionId: 'child',
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
clientTimeZone: 'Asia/Shanghai',
|
||||
}).clientTimeZone).toBe('Asia/Shanghai')
|
||||
expect(subagentPromptRequestSchema.parse({
|
||||
parentSessionId: 'parent',
|
||||
childSessionId: 'child',
|
||||
mode: 'continuable',
|
||||
content: [],
|
||||
}).clientTimeZone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
|
||||
Reference in New Issue
Block a user