mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
Merge latest master human inbox controls into V3 integration
Preserve the V3 PTC scenario while enabling the current subagent human-steering fixture environment. All product changes are inherited from master; no V3 migration semantics are altered.
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/api/session-controller/README.md
|
||||
README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6
|
||||
README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe
|
||||
README.md: a2639ebd5fb648ed193d7c3575a649891ebf76b9
|
||||
README.zh.md: 7bed20d3e47855b045bb838b354328f1769273e2
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
|
||||
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
|
||||
历史页与 follow opening snapshot 为每个持久 Session event 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑 stream 内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
|
||||
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`,即轮次跳转加载器,按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^"
|
||||
},
|
||||
@@ -145,6 +146,7 @@
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export interface ISession {
|
||||
attachmentId: AttachmentIdType,
|
||||
): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
|
||||
/**
|
||||
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
|
||||
* Apply one edit, remove, or Steer action to a still-pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - requested queue operation.
|
||||
* @returns acceptance, or a business/transport error.
|
||||
|
||||
@@ -271,6 +271,7 @@ export class Session implements SessionFace {
|
||||
parentSessionId: this.address.parentSessionId,
|
||||
childSessionId: this.address.childSessionId,
|
||||
mode: 'continuable',
|
||||
delivery: mode,
|
||||
content: routedContent,
|
||||
clientTimeZone: resolvedClientTimeZone(),
|
||||
}, signal)
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deeps
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
@@ -415,12 +416,18 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
const agent = this.ctx.agents.get(request.sessionId)
|
||||
if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
||||
throw apiSessionSubagentOwnershipError(request.sessionId)
|
||||
}
|
||||
if (agent === undefined) {
|
||||
throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
|
||||
}
|
||||
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
||||
const identity = this.ctx.sessionProjections
|
||||
.snapshot(agent.session, ['subagent'])
|
||||
.values.subagent
|
||||
if (identity?.mode !== 'continuable'
|
||||
|| !agent.session.isOwnSeq(identity.seq)) {
|
||||
throw apiSessionSubagentOwnershipError(request.sessionId)
|
||||
}
|
||||
}
|
||||
const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
|
||||
const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
|
||||
const located = nextTurn === undefined
|
||||
@@ -433,20 +440,28 @@ export class SessionCommandController {
|
||||
if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
|
||||
throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
|
||||
}
|
||||
if (request.action.kind === 'edit') {
|
||||
agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
|
||||
...message,
|
||||
content: [...request.action.content],
|
||||
}))
|
||||
} else {
|
||||
agent.inbox.remove(request.itemId)
|
||||
if (request.action.kind === 'remove') {
|
||||
switch (request.action.kind) {
|
||||
case 'edit':
|
||||
agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
|
||||
...message,
|
||||
content: [...request.action.content],
|
||||
}))
|
||||
break
|
||||
case 'remove': {
|
||||
agent.inbox.remove(request.itemId)
|
||||
const source = message.source
|
||||
if (source.kind === 'user' && 'rpcId' in source) {
|
||||
this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
|
||||
}
|
||||
break
|
||||
}
|
||||
if (request.action.kind === 'steer') agent.steer(message)
|
||||
case 'steer':
|
||||
agent.inbox.remove(request.itemId)
|
||||
agent.steer(message)
|
||||
break
|
||||
/* v8 ignore next 2 -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(request.action, 'queue action')
|
||||
}
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, {
|
||||
SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
|
||||
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ApiSessionAgentController } from '../src/agent.ts'
|
||||
import { SessionCommandController } from '../src/commands.ts'
|
||||
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
|
||||
|
||||
async function commandHarness(): Promise<{
|
||||
async function commandHarness(
|
||||
childMode?: 'continuable' | 'seeded-continuable' | 'seed-only' | 'one-shot' | 'unknown' | 'corrupt',
|
||||
): Promise<{
|
||||
ctx: Context
|
||||
controller: SessionCommandController
|
||||
agent: Agent
|
||||
@@ -22,9 +28,48 @@ async function commandHarness(): Promise<{
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } })
|
||||
installSessionReadTestServices(ctx)
|
||||
ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
|
||||
const sessionId = SessionId('commands-session')
|
||||
const ancestor = Session.create(SessionId('ancestor'))
|
||||
ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({
|
||||
mode: 'continuable', provider: 'test', label: 'ancestor',
|
||||
}))
|
||||
// A seeded child inherits exactly the ancestor prefix; its own descriptor
|
||||
// is appended after creation, as the continuation manager does. `seed-only`
|
||||
// never appends one: the identity folds as continuable, but from the
|
||||
// inherited prefix rather than this Session's own suffix.
|
||||
const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only'
|
||||
? ancestor.snapshotEvents()
|
||||
: undefined
|
||||
const session = ctx.sessions.create(sessionId, {
|
||||
...lineage === undefined ? {} : { seed: lineage, inheritedEventCount: SessionLogOffset(lineage.length) },
|
||||
meta: {
|
||||
cwd: '/workspace',
|
||||
...(childMode === undefined ? {} : {
|
||||
origin: 'subagent' as const,
|
||||
parentSession: SessionId('offline-parent'),
|
||||
}),
|
||||
...lineage === undefined ? {} : { isSeeded: true },
|
||||
},
|
||||
})
|
||||
if (childMode === 'continuable' || childMode === 'seeded-continuable') {
|
||||
session.append('subagent/descriptor', snapshotSubagentDescriptor({
|
||||
mode: 'continuable', provider: 'test', label: 'child',
|
||||
}))
|
||||
} else if (childMode === 'one-shot') {
|
||||
session.append('subagent/descriptor', snapshotSubagentDescriptor({
|
||||
mode: 'one-shot', provider: 'test', label: 'child',
|
||||
}))
|
||||
} else if (childMode === 'corrupt') {
|
||||
session.append('subagent/descriptor', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 1,
|
||||
} as never)
|
||||
}
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
const steer = vi.fn()
|
||||
const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) })
|
||||
const cancel = vi.fn()
|
||||
const agent = {
|
||||
id: session.id,
|
||||
@@ -52,7 +97,14 @@ async function commandHarness(): Promise<{
|
||||
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
|
||||
composeAgent: () => Promise.resolve({ setup: () => {} }),
|
||||
} as unknown as ApiSessionAgentController
|
||||
return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), agent, inbox, steer, cancel }
|
||||
return {
|
||||
ctx,
|
||||
controller: new SessionCommandController(ctx, agents, '/workspace'),
|
||||
agent,
|
||||
inbox,
|
||||
steer,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
|
||||
@@ -100,6 +152,9 @@ describe('Session queue commands', () => {
|
||||
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
|
||||
})).toEqual({ accepted: true })
|
||||
expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
|
||||
// An edit rewrites content in place, so the occurrence a client addressed
|
||||
// by id stays addressable.
|
||||
expect(inbox.nextTurn[0]?.id).toBe(queued.id)
|
||||
expect(controller.updateQueue({
|
||||
sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
|
||||
})).toEqual({ accepted: true })
|
||||
@@ -136,6 +191,81 @@ describe('Session queue commands', () => {
|
||||
expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['continuable', 'seeded-continuable'] as const)(
|
||||
'mutates both inbox destinations of a live %s child while its parent is offline',
|
||||
async (childMode) => {
|
||||
const { ctx, controller, agent, inbox, steer } = await commandHarness(childMode)
|
||||
const queued = createUserMessage({
|
||||
content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
|
||||
})
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
inbox.append('next-turn', queued)
|
||||
inbox.append('next-step', context)
|
||||
|
||||
expect(controller.updateQueue({
|
||||
sessionId: agent.id,
|
||||
itemId: context.id,
|
||||
action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] },
|
||||
})).toEqual({ accepted: true })
|
||||
const editedContext = inbox.nextStep[0]
|
||||
expect(editedContext).toMatchObject({
|
||||
content: [{ type: 'text', text: 'edited context' }],
|
||||
source: context.source,
|
||||
})
|
||||
expect(editedContext?.id).toBe(context.id)
|
||||
if (editedContext === undefined) throw new Error('missing edited context')
|
||||
expect(controller.updateQueue({
|
||||
sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
|
||||
})).toEqual({ accepted: true })
|
||||
expect(controller.updateQueue({
|
||||
sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
|
||||
})).toEqual({ accepted: true })
|
||||
expect(steer).toHaveBeenCalledWith(queued)
|
||||
await ctx.fiber.dispose()
|
||||
},
|
||||
)
|
||||
|
||||
it('removes the selected message before handing it to Agent steering', async () => {
|
||||
const { ctx, controller, agent, inbox, steer } = await commandHarness('continuable')
|
||||
const first = createUserMessage({
|
||||
content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
|
||||
})
|
||||
const second = createUserMessage({
|
||||
content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
|
||||
})
|
||||
inbox.append('next-turn', first)
|
||||
inbox.append('next-turn', second)
|
||||
// Stand in for the Agent's cancellation-convergence destination; the
|
||||
// command must accept whichever boundary `Agent.steer()` selects.
|
||||
steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) })
|
||||
|
||||
expect(controller.updateQueue({
|
||||
sessionId: agent.id, itemId: first.id, action: { kind: 'steer' },
|
||||
})).toEqual({ accepted: true })
|
||||
expect(steer).toHaveBeenCalledWith(first)
|
||||
// Ordering proves the removal happened before delivery rather than after.
|
||||
expect(inbox.nextTurn).toEqual([second, first])
|
||||
expect(inbox.nextStep).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps one-shot, seed-only, missing, and malformed child descriptors behind the ownership fence', async () => {
|
||||
for (const mode of ['one-shot', 'seed-only', 'unknown', 'corrupt'] as const) {
|
||||
const { ctx, controller, agent, inbox } = await commandHarness(mode)
|
||||
const queued = createUserMessage({
|
||||
content: [{ type: 'text', text: mode }], source: { kind: 'user' },
|
||||
})
|
||||
inbox.append('next-turn', queued)
|
||||
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
|
||||
sessionId: agent.id, itemId: queued.id, action: { kind: 'remove' },
|
||||
})), 'session/agent-busy')
|
||||
expect(inbox.nextTurn).toEqual([queued])
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function imageRef(id: string): ImageAttachmentRef {
|
||||
|
||||
@@ -320,6 +320,7 @@ describe('subagent catalogs', () => {
|
||||
requestId: expect.any(String) as unknown as string,
|
||||
parentSessionId: S1, childSessionId: S2,
|
||||
mode: 'continuable',
|
||||
delivery: 'queue',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
|
||||
@@ -400,9 +400,11 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer')
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(steered).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.follow')).toEqual([
|
||||
{
|
||||
@@ -419,9 +421,18 @@ describe('prompt and cancel errors', () => {
|
||||
requestId: expect.any(String) as unknown as string,
|
||||
parentSessionId: PARENT, childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
delivery: 'queue',
|
||||
content: [{ type: 'text', text: '继续' }],
|
||||
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
{
|
||||
requestId: expect.any(String) as unknown as string,
|
||||
parentSessionId: PARENT, childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
delivery: 'steer',
|
||||
content: [{ type: 'text', text: '现在处理' }],
|
||||
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('subagents.interruptByParent')).toEqual([
|
||||
{ childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
|
||||
@@ -456,6 +467,7 @@ describe('prompt and cancel errors', () => {
|
||||
requestId: expect.any(String) as unknown as string,
|
||||
parentSessionId: PARENT, childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
delivery: 'queue',
|
||||
content,
|
||||
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
{ "path": "../../skill/skill" },
|
||||
{ "path": "../../subagent/subagent" },
|
||||
{ "path": "../../util/time" },
|
||||
{ "path": "../../util/values" },
|
||||
{ "path": "../../typert/protocol" },
|
||||
{ "path": "../../typert/registry" },
|
||||
{ "path": "../../workspace/workspace" }
|
||||
|
||||
Reference in New Issue
Block a user