Merge master into fix/subagent-stack-end-result
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/client/runtime/README.md
|
||||
README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c
|
||||
README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a
|
||||
README.md: eca7db1f9b2d5c7e28fa86a363ca4408703b99df
|
||||
README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77
|
||||
|
||||
@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
## Pending queue projection
|
||||
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot and carries both queued and pending-steering occurrences with their resolved placement. Each row carries its `InboxItemId`, stable `MessageId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection, while an accepted live `steering/message` event retires only the first matching current steering occurrence so the durable node can take over before the following Host snapshot; history replay never consumes a later occurrence that reused the same `MessageId`. Reconnect buffering retains only the latest snapshot, and neither ordinary durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`.
|
||||
|
||||
## The human transcript
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## 待处理队列投影
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。
|
||||
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照,携带 queued 与待处理 steering(中途引导)单次入队项及其已解析 placement。每行都携带其 `InboxItemId`、稳定的 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;已接纳的实时 `steering/message` 事件则只退役第一个匹配的当前 steering 单次入队项,让持久节点能在下一份 Host 快照之前接管,而历史回放绝不会消费后来复用同一 `MessageId` 的单次入队项。重连缓冲只保留最新快照,普通持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑、移除和严格 steering 操作,不进行乐观更新;认领与窗口关闭竞态分别会返回 `queue-item-not-found` 和 `steer-unavailable`。
|
||||
|
||||
## 面向人的 transcript(文本记录)
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ export interface ISession {
|
||||
*/
|
||||
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Apply one mutation to a still-pending queue occurrence.
|
||||
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - edit or remove operation.
|
||||
* @param action - requested queue operation.
|
||||
* @returns acceptance, or a business/transport error.
|
||||
*/
|
||||
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
|
||||
|
||||
@@ -146,7 +146,8 @@ function materializeNode(
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
kind: 'steering', messageId: event.data.message.id,
|
||||
seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -104,6 +105,8 @@ export interface AssistantMessageNode {
|
||||
/** A steering message injected mid-turn. */
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
/** Stable identity shared with its pre-admission inbox occurrence. */
|
||||
messageId: MessageId
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
@@ -271,9 +274,15 @@ export interface RunningToolCall {
|
||||
}
|
||||
|
||||
|
||||
/** One independently addressable row from the transient queue snapshot. */
|
||||
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedMessage {
|
||||
readonly id: InboxItemId
|
||||
/** Stable message identity used for transient-to-durable steering handoff. */
|
||||
readonly messageId: MessageId
|
||||
/** Agent-resolved placement; only queued rows accept queue mutations. */
|
||||
readonly placement: 'queued' | 'steering'
|
||||
/** Complete content used to render pending steering before it becomes durable. */
|
||||
readonly content: readonly ContentBlock[]
|
||||
readonly preview: string
|
||||
/** Complete editable text; null when the message contains non-text blocks. */
|
||||
readonly text: string | null
|
||||
@@ -332,7 +341,7 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
pending: readonly PendingInteraction[]
|
||||
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
|
||||
/** Authoritative transient inbox snapshot, including queued and steering placements. */
|
||||
queue: readonly QueuedMessage[]
|
||||
running: boolean
|
||||
/**
|
||||
|
||||
@@ -446,6 +446,9 @@ export class Session implements SessionFace {
|
||||
case 'session/queue': {
|
||||
this.queued = frame.items.map(item => ({
|
||||
id: item.id,
|
||||
messageId: item.message.id,
|
||||
placement: item.placement,
|
||||
content: item.message.content,
|
||||
preview: queuePreviewOf(item.message.content),
|
||||
text: queueTextOf(item.message.content),
|
||||
}))
|
||||
@@ -647,9 +650,20 @@ export class Session implements SessionFace {
|
||||
this.events.push(event)
|
||||
this.views.push(view)
|
||||
this.transcript.append(event, view)
|
||||
this.handoffPendingSteering(event)
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
|
||||
/** Retire the first matching live steering occurrence when its durable event takes over. */
|
||||
private handoffPendingSteering(event: SessionEvent): void {
|
||||
if (event.type !== 'steering/message') return
|
||||
const index = this.queued.findIndex(item =>
|
||||
item.placement === 'steering' && item.messageId === event.data.message.id)
|
||||
if (index === -1) return
|
||||
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
|
||||
this.queueRev++
|
||||
}
|
||||
|
||||
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
|
||||
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
|
||||
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
|
||||
|
||||
@@ -74,7 +74,8 @@ function materializeNode(
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
kind: 'steering', messageId: event.data.message.id,
|
||||
seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
InboxItemId, MuxFrame, RpcId, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -22,6 +23,8 @@ interface QueueFixture {
|
||||
id: string
|
||||
body: string
|
||||
content?: ContentBlock[]
|
||||
placement?: 'queued' | 'steering'
|
||||
message?: UserMessage
|
||||
}
|
||||
|
||||
/** Build one authoritative queue snapshot. */
|
||||
@@ -31,7 +34,8 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
|
||||
sessionId: SID,
|
||||
items: items.map(item => ({
|
||||
id: iid(item.id),
|
||||
message: createUserMessage({
|
||||
placement: item.placement ?? 'queued',
|
||||
message: item.message ?? createUserMessage({
|
||||
content: item.content ?? text(item.body),
|
||||
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
|
||||
}),
|
||||
@@ -49,8 +53,14 @@ describe('queue snapshot intake', () => {
|
||||
session.handleMuxEnvelope(rid('env-1'), queueFrame([
|
||||
{ id: 'q-1', body: '第一条 排队\n消息' },
|
||||
]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-1', placement: 'queued',
|
||||
content: [{ type: 'text', text: '第一条 排队\n消息' }],
|
||||
preview: '第一条 排队 消息', text: '第一条 排队\n消息',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -61,8 +71,14 @@ describe('queue snapshot intake', () => {
|
||||
body: '',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
}]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-image', preview: 'hi [image]', text: null },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-image', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
|
||||
preview: 'hi [image]', text: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -85,8 +101,14 @@ describe('queue snapshot intake', () => {
|
||||
session.handleMuxEnvelope(rid('env-5'), queueFrame([
|
||||
{ id: 'q-2', body: 'two edited' },
|
||||
]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-2', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'two edited' }],
|
||||
preview: 'two edited', text: 'two edited',
|
||||
},
|
||||
])
|
||||
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
@@ -99,6 +121,55 @@ describe('queue snapshot intake', () => {
|
||||
session.handleAgentError('unrelated')
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
|
||||
it('retains steering placement and complete content in the same authoritative snapshot', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-steering'), queueFrame([
|
||||
{ id: 'q-next', body: 'later' },
|
||||
{ id: 's-now', body: 'interrupt now', placement: 'steering' },
|
||||
]))
|
||||
|
||||
expect(session.getSnapshot().queue.map(item => ({
|
||||
id: item.id, placement: item.placement, content: item.content,
|
||||
}))).toEqual([
|
||||
{ id: 'q-next', placement: 'queued', content: text('later') },
|
||||
{ id: 's-now', placement: 'steering', content: text('interrupt now') },
|
||||
])
|
||||
})
|
||||
|
||||
it('hands off exactly one current occurrence when live steering becomes durable', async () => {
|
||||
const session = makeSession()
|
||||
await session.open()
|
||||
const message = createUserMessage({
|
||||
content: text('same message'),
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.handleMuxEnvelope(rid('env-same-id'), queueFrame([
|
||||
{ id: 's-first', body: '', placement: 'steering', message },
|
||||
{ id: 's-second', body: '', placement: 'steering', message },
|
||||
]))
|
||||
const durable = {
|
||||
seq: 0,
|
||||
time: 1_700_000_000_000,
|
||||
type: 'steering/message',
|
||||
surfaceOp: 'append',
|
||||
data: { turn: 1, message },
|
||||
} as SessionEvent
|
||||
|
||||
session.handleMuxEnvelope(rid('env-durable'), {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'steering')).toHaveLength(1)
|
||||
|
||||
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
|
||||
{ id: 's-later', body: '', placement: 'steering', message },
|
||||
]))
|
||||
session.handleMuxEnvelope(rid('env-replayed-durable'), {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue operation transport', () => {
|
||||
@@ -110,11 +181,20 @@ describe('queue operation transport', () => {
|
||||
|
||||
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
|
||||
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.updateQueue')).toEqual([{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'edit', content: text('next') },
|
||||
}])
|
||||
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
|
||||
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.updateQueue')).toEqual([
|
||||
{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'edit', content: text('next') },
|
||||
},
|
||||
{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'steer' },
|
||||
},
|
||||
])
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/client/ui-conversation/README.md
|
||||
README.md: 65e4542e622713e2cd120906926fc0601ef280bd
|
||||
README.zh.md: 4828ad214bb5ec0a9921307dd01dcc282910b330
|
||||
README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439
|
||||
README.zh.md: 268e05a806db1468ba689608c178646af132fe25
|
||||
|
||||
@@ -16,6 +16,8 @@ The session header declares and renders the session-scoped `'conversation.sessio
|
||||
|
||||
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
@@ -32,9 +34,13 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions.
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
|
||||
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority.
|
||||
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
@@ -62,5 +68,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
|
||||
- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay.
|
||||
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels.
|
||||
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
@@ -32,9 +34,13 @@
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。
|
||||
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
@@ -62,5 +68,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
|
||||
- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。
|
||||
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。
|
||||
- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `steering/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
@@ -16,7 +16,10 @@ import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
|
||||
import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
@@ -93,6 +96,22 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy()
|
||||
|
||||
ctx.effect(() => {
|
||||
const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'composer-enter',
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: (): EnterBehaviorRowInjected => ({
|
||||
hooks: { busyEnter: submissionPolicy.busyEnter },
|
||||
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
|
||||
}),
|
||||
}, EnterBehaviorRow))
|
||||
return () => { row.dispose() }
|
||||
}, 'ui-conversation: Enter behavior settings row')
|
||||
|
||||
// Chat scroll offsets by session, surviving view switches (the chat view
|
||||
// unmounts under the tab ring). Deliberately not persisted: a fresh page
|
||||
@@ -203,6 +222,8 @@ export function apply(ctx: Context): void {
|
||||
if (sessionId === undefined) {
|
||||
return {
|
||||
keyboard: undefined,
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) =>
|
||||
submissionPolicy.resolve(running, gesture, steeringAvailable),
|
||||
toggleCommandMenu: undefined,
|
||||
stop: undefined,
|
||||
command: undefined,
|
||||
@@ -213,6 +234,8 @@ export function apply(ctx: Context): void {
|
||||
const slash = inputHub.slash(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) =>
|
||||
submissionPolicy.resolve(running, gesture, steeringAvailable),
|
||||
toggleCommandMenu: slash === undefined
|
||||
? undefined
|
||||
: (selection) => {
|
||||
|
||||
@@ -39,6 +39,13 @@ function firstLine(text: string): string {
|
||||
return nl === -1 ? text : text.slice(0, nl)
|
||||
}
|
||||
|
||||
/** Latest non-blank reasoning line while the block is still streaming. */
|
||||
function latestLine(text: string): string {
|
||||
const visible = text.trimEnd()
|
||||
const nl = visible.lastIndexOf('\n')
|
||||
return nl === -1 ? visible : visible.slice(nl + 1)
|
||||
}
|
||||
|
||||
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
|
||||
function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
const parts: string[] = []
|
||||
@@ -61,7 +68,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
variant="think"
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
summary={firstLine(text)}
|
||||
summary={running ? latestLine(text) : firstLine(text)}
|
||||
body={text}
|
||||
state={running ? 'running' : 'ok'}
|
||||
/>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
@@ -236,6 +236,7 @@ export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const inbox = useSession(s => s.queue)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const running = useSession(s => s.running)
|
||||
@@ -248,6 +249,10 @@ export function ChatView({
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
const pendingSteering = useMemo(
|
||||
() => inbox.filter(item => item.placement === 'steering'),
|
||||
[inbox],
|
||||
)
|
||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
||||
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
@@ -261,6 +266,7 @@ export function ChatView({
|
||||
const firstSeqRef = useRef<number | null>(null)
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
const lastSteeringIdRef = useRef<string | null>(null)
|
||||
/** Flow tip signature — follow-scroll only when this moves, never on a
|
||||
* scroll-driven at-bottom chrome re-render (that was snapping inertial
|
||||
* scrolls the rest of the way to the floor). */
|
||||
@@ -269,7 +275,8 @@ export function ChatView({
|
||||
const firstSeq = nodes[0]?.seq ?? null
|
||||
const lastItem = items[items.length - 1]
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
|
||||
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
|
||||
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
@@ -298,6 +305,7 @@ export function ChatView({
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastKey
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
@@ -308,6 +316,7 @@ export function ChatView({
|
||||
firstSeqRef.current = firstSeq
|
||||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||||
lastKeyRef.current = lastKey
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
@@ -316,12 +325,14 @@ export function ChatView({
|
||||
// (send lives in the composer, so arrival is detected here, not armed there).
|
||||
const appendedUser = lastKey !== lastKeyRef.current
|
||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||||
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
|
||||
const tipMoved = followSigRef.current !== followSig
|
||||
lastKeyRef.current = lastKey
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
// Follow new flow content while pinned; do NOT re-pin on every render
|
||||
// merely because atBottomRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
})
|
||||
|
||||
const onScrollRef = useRef(() => {})
|
||||
@@ -467,6 +478,9 @@ export function ChatView({
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnStatus />}
|
||||
{pendingSteering.map(item => (
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
))}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<div className={css.toBottomSlot}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||
// branch wired through onBranch, date-aware clock.
|
||||
// Shared IconActions chrome for user, steering, and assistant messages: copy
|
||||
// live, optional branch wiring, and an optional date-aware clock.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
@@ -13,12 +13,14 @@ import css from './MessageIconActions.module.css'
|
||||
export interface MessageIconActionsProps {
|
||||
/** Plain text the copy action writes. */
|
||||
text: string
|
||||
/** Unix epoch ms for the clock label. */
|
||||
time: number
|
||||
/** Unix epoch ms for the clock label; omitted for transient messages. */
|
||||
time?: number | undefined
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** Fork the session at this message. */
|
||||
onBranch?: (() => void) | undefined
|
||||
/** Whether to render the branch action; defaults to true. */
|
||||
showBranch?: boolean | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -31,13 +33,13 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, onBranch, className, t,
|
||||
text, time, clock, onBranch, showBranch = true, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
const clockEl = (
|
||||
const clockEl = time === undefined ? null : (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, t, day)}
|
||||
</span>
|
||||
@@ -50,11 +52,13 @@ export function MessageIconActions({
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('message.branch')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showBranch && (
|
||||
<Tooltip label={t('message.branch')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned, with
|
||||
// clock + copy / branch IconActions), steering (same bubble, no actions),
|
||||
// context injection, compaction marker, retry disclosure, and
|
||||
// MessageItem: simple chat nodes — user and consumed-steering bubbles
|
||||
// (right-aligned, with clock + copy / branch IconActions), pending steering
|
||||
// (copy only), context injection, compaction marker, retry disclosure, and
|
||||
// unknown-surface JSON rows.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
@@ -167,19 +167,21 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/** Right-aligned bubble shared by user and steering rows (steering has no actions). */
|
||||
/** Right-aligned bubble shared by user and steering rows. */
|
||||
function UserStyleBubble({
|
||||
content, actions, t,
|
||||
content, actions, pending = false, t,
|
||||
}: {
|
||||
content: readonly unknown[]
|
||||
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
|
||||
actions?: (text: string) => ReactNode
|
||||
/** Whether this is the Host-authoritative pre-admission steering projection. */
|
||||
pending?: boolean
|
||||
t: ChatViewSlotProps['t']
|
||||
}): ReactNode {
|
||||
const { text, rest } = contentText(content)
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined}>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
@@ -189,12 +191,41 @@ function UserStyleBubble({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one Host-authoritative pending steering item with the same visual
|
||||
* language as its eventual durable transcript node.
|
||||
* @param props - Pending message content and conversation translator.
|
||||
* @returns the pending steering bubble.
|
||||
*/
|
||||
export function PendingSteeringBubble({ content, t }: {
|
||||
content: readonly unknown[]
|
||||
t: ChatViewSlotProps['t']
|
||||
}): ReactNode {
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={content}
|
||||
pending
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
clock="start"
|
||||
showBranch={false}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, onFork, t,
|
||||
}: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={node.content}
|
||||
@@ -211,8 +242,6 @@ export const MessageItem = memo(function MessageItem({
|
||||
)}
|
||||
/>
|
||||
)
|
||||
case 'steering':
|
||||
return <UserStyleBubble content={node.content} t={t} />
|
||||
case 'context':
|
||||
return (
|
||||
<ContextInjectionRow content={node.content} source={node.source} t={t} />
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Live reasoning follows its one-line summary to the inline end. */
|
||||
.summary[data-follow-end] {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// Enter / Space, icon→chevron hover preview). The collapsed row is always
|
||||
// one line; every row with body, output, or a card material (terminal, diff,
|
||||
// read, search, web) is expandable; the summary stays inline while open,
|
||||
// except Think, whose body opens with the same first line and would repeat it.
|
||||
// except Think, where the running collapsed row follows the latest line at its
|
||||
// scroll end and the summary yields while open to avoid repeating the body.
|
||||
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
|
||||
// text input/output, the run_code program through CodeBlock, or a card
|
||||
// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
|
||||
@@ -19,7 +20,7 @@
|
||||
// independent); an error row's collapsed summary is the failure's first line in
|
||||
// the error color.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
||||
@@ -152,6 +153,7 @@ export function ToolRow({
|
||||
inspect,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const summaryRef = useRef<HTMLSpanElement>(null)
|
||||
const terminalBody = terminal ?? null
|
||||
const diffBody = diff ?? null
|
||||
const readBody = read ?? null
|
||||
@@ -173,6 +175,15 @@ export function ToolRow({
|
||||
const summaryText = failureLine ?? summary
|
||||
// The failure line is error prose, not the path: no open-file affordance.
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const isThink = variant === 'think'
|
||||
const followSummaryEnd = isThink && state === 'running' && !open
|
||||
useLayoutEffect(() => {
|
||||
const summaryElement = summaryRef.current
|
||||
if (summaryElement === null) return
|
||||
summaryElement.scrollLeft = followSummaryEnd
|
||||
? summaryElement.scrollWidth - summaryElement.clientWidth
|
||||
: 0
|
||||
}, [followSummaryEnd, summaryText])
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
@@ -188,9 +199,8 @@ export function ToolRow({
|
||||
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
|
||||
}
|
||||
// Think reasoning is prose, not an input payload: expanded, it renders as
|
||||
// plain indented text (no IN/OUT card) and the inline summary — the body's
|
||||
// own first line — yields to avoid repeating itself.
|
||||
const isThink = variant === 'think'
|
||||
// plain indented text (no IN/OUT card) and the inline summary yields to avoid
|
||||
// repeating the body.
|
||||
// The code variant's program renders through CodeBlock (shiki), so only its
|
||||
// output joins the IN/OUT card; every other variant's input does too.
|
||||
const cardBody = variant === 'code' ? null : body
|
||||
@@ -227,7 +237,11 @@ export function ToolRow({
|
||||
{summaryText}
|
||||
</button>
|
||||
) : (
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
<span
|
||||
ref={isThink ? summaryRef : undefined}
|
||||
className={clsx(css.summary, failureLine !== null && css.errorSummary)}
|
||||
data-follow-end={followSummaryEnd || undefined}
|
||||
>
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Composer submission vocabulary shared by the input and settings domains. */
|
||||
|
||||
/** Delivery mode requested for one ordinary composer message. */
|
||||
export type InputSubmitMode = 'queue' | 'steer'
|
||||
|
||||
/** Configurable meaning of plain Enter while the addressed agent is busy. */
|
||||
export type BusyEnterBehavior = InputSubmitMode
|
||||
|
||||
/** Keyboard gesture whose delivery mode the submission policy resolves. */
|
||||
export type ComposerSubmitGesture = 'enter' | 'accelerated'
|
||||
@@ -7,6 +7,7 @@ import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInte
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -285,6 +286,12 @@ export interface ComposerBarOwnerProps {
|
||||
export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
|
||||
keyboard: ComposerKeyboard | undefined
|
||||
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
|
||||
resolveSubmitMode: (
|
||||
running: boolean,
|
||||
gesture: ComposerSubmitGesture,
|
||||
steeringAvailable: boolean,
|
||||
) => InputSubmitMode
|
||||
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
|
||||
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
|
||||
/** Cancel the in-flight turn; absent with the session. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { QueueRow } from '../contract/queue.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
|
||||
/**
|
||||
* The scoped-event application verbs: the hub's bail listeners call these,
|
||||
@@ -28,8 +29,11 @@ export interface InputTarget {
|
||||
export interface SessionInput extends InputTarget {
|
||||
/** Single write path for draft text (all mutation rides machine events). */
|
||||
setDraft(text: string): void
|
||||
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
|
||||
submit(): void
|
||||
/**
|
||||
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
|
||||
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
|
||||
*/
|
||||
submit(mode?: InputSubmitMode): void
|
||||
/**
|
||||
* Surface a notice outside the machine's own effect stream: detached
|
||||
* command results and business notifications render through here.
|
||||
@@ -82,8 +86,8 @@ export interface ComposerKeyboard {
|
||||
readonly snapshot: InputState
|
||||
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
|
||||
setDraft(text: string, editRange?: EditRange): void
|
||||
/** Newline at the selection as a machine transaction (Ctrl+Enter path). */
|
||||
newline(selection: EditSelection): void
|
||||
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
|
||||
submit(mode: InputSubmitMode): void
|
||||
undo(): void
|
||||
redo(): void
|
||||
/** Paste over the selection (sync components ride the same transaction). */
|
||||
@@ -191,7 +195,7 @@ export interface InputState {
|
||||
readonly occurrences: readonly Occurrence[]
|
||||
/** Live paste-match attempt (absent when no paste is matchable). */
|
||||
readonly paste?: PasteAttemptState
|
||||
/** Read-only queue projection (session/queued frames + connect snapshot). */
|
||||
/** Read-only transient inbox projection (`session/queue`, including pending steering). */
|
||||
readonly queue: readonly QueuedMessage[]
|
||||
}
|
||||
|
||||
@@ -206,6 +210,8 @@ export interface SubmitAttempt {
|
||||
readonly signal: AbortSignal
|
||||
/** Draft at enter time; rollback restores it only while the live draft still equals it. */
|
||||
readonly draftSnapshot: string
|
||||
/** Default-message delivery intent retained while slash adjudication is pending. */
|
||||
readonly mode: InputSubmitMode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,8 +223,6 @@ export interface SubmitAttempt {
|
||||
export type InputEvent =
|
||||
/** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
|
||||
| { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
|
||||
/** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */
|
||||
| { readonly type: 'newline'; readonly selection: EditSelection }
|
||||
| { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
|
||||
/** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */
|
||||
| { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
|
||||
@@ -239,7 +243,7 @@ export type InputEvent =
|
||||
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
|
||||
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
|
||||
| { readonly type: 'invalidate-paste' }
|
||||
| { readonly type: 'enter' }
|
||||
| { readonly type: 'enter'; readonly mode: InputSubmitMode }
|
||||
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
|
||||
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
|
||||
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
|
||||
@@ -258,5 +262,5 @@ export type InputEvent =
|
||||
export type InputEffect =
|
||||
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
|
||||
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
|
||||
| { readonly type: 'default-sink'; readonly draft: string }
|
||||
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode }
|
||||
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
|
||||
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
|
||||
} from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import { InputMachine } from './machine.ts'
|
||||
|
||||
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
|
||||
@@ -39,7 +40,7 @@ export interface SessionInputDeps {
|
||||
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
|
||||
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
|
||||
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
|
||||
defaultSink(text: string): void
|
||||
defaultSink(text: string, mode: InputSubmitMode): void
|
||||
}
|
||||
|
||||
/** Guard tier from the machine phase. */
|
||||
@@ -68,7 +69,7 @@ export class SessionInputShell implements SessionInput {
|
||||
/** The public provide-channel action face (one stable identity per session — decision 20). */
|
||||
readonly actions: InputActions = {
|
||||
setDraft: (text) => { this.setDraft(text) },
|
||||
submit: () => { this.submit() },
|
||||
submit: () => { this.submit('queue') },
|
||||
}
|
||||
|
||||
// Real wall clock: the typing-run merge window must actually expire in
|
||||
@@ -106,15 +107,6 @@ export class SessionInputShell implements SessionInput {
|
||||
this.run(this.core.dispatch({ type: 'send-committed' }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a newline at the selection as one machine transaction (the
|
||||
* execCommand path is gone — a second undo history would fork).
|
||||
* @param selection - current DOM selection in draft coordinates.
|
||||
*/
|
||||
newline(selection: EditSelection): void {
|
||||
this.run(this.core.dispatch({ type: 'newline', selection }))
|
||||
}
|
||||
|
||||
/** Undo the latest transaction (InputBar intercepts the platform chord). */
|
||||
undo(): void {
|
||||
this.run(this.core.dispatch({ type: 'undo' }))
|
||||
@@ -152,8 +144,8 @@ export class SessionInputShell implements SessionInput {
|
||||
* (adjudicating/submitting) force-closes the transient layers: the popup
|
||||
* dismisses and the menu tracks frozen.
|
||||
*/
|
||||
submit(): void {
|
||||
this.run(this.core.dispatch({ type: 'enter' }))
|
||||
submit(mode: InputSubmitMode = 'queue'): void {
|
||||
this.run(this.core.dispatch({ type: 'enter', mode }))
|
||||
const phase = this.snapshot.phase
|
||||
if (phase === 'adjudicating' || phase === 'submitting') {
|
||||
this.deps.popup?.()?.dismiss()
|
||||
@@ -338,7 +330,7 @@ export class SessionInputShell implements SessionInput {
|
||||
return
|
||||
}
|
||||
case 'default-sink': {
|
||||
this.sinkSerialized(fx.draft)
|
||||
this.sinkSerialized(fx.draft, fx.mode)
|
||||
return
|
||||
}
|
||||
default:
|
||||
@@ -353,10 +345,10 @@ export class SessionInputShell implements SessionInput {
|
||||
* send — notice + draft and chips retained, never a silent downgrade to
|
||||
* the clipboard text. Chip-free drafts skip the async detour.
|
||||
*/
|
||||
private sinkSerialized(draft: string): void {
|
||||
private sinkSerialized(draft: string, mode: InputSubmitMode): void {
|
||||
const occurrences = this.core.state.occurrences
|
||||
if (occurrences.length === 0) {
|
||||
this.deps.defaultSink(draft.trim())
|
||||
this.deps.defaultSink(draft.trim(), mode)
|
||||
return
|
||||
}
|
||||
const slash = this.deps.slash?.()
|
||||
@@ -376,7 +368,7 @@ export class SessionInputShell implements SessionInput {
|
||||
cursor = part.offset + 1
|
||||
}
|
||||
out += draft.slice(cursor)
|
||||
this.deps.defaultSink(out.trim())
|
||||
this.deps.defaultSink(out.trim(), mode)
|
||||
},
|
||||
(error: unknown) => {
|
||||
controller.abort()
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId }
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
import { SessionInputShell } from './facade.ts'
|
||||
|
||||
@@ -56,7 +57,7 @@ export class InputHub implements InputService {
|
||||
slash: () => this.controller(actx),
|
||||
popup: () => this.popup(actx),
|
||||
queue: queueReadFaceOf(session),
|
||||
defaultSink: (text) => { this.sink(session, text) },
|
||||
defaultSink: (text, mode) => { this.sink(session, text, mode) },
|
||||
})
|
||||
this.shells.set(id, shell)
|
||||
// The one teardown axis: listeners, shell, and map entries all ride the
|
||||
@@ -123,12 +124,12 @@ export class InputHub implements InputService {
|
||||
* exactly one path; a failed first prompt is an ordinary prompt failure
|
||||
* (error strip via promptError, draft restored only while untouched).
|
||||
*/
|
||||
private sink(session: SessionFace, text: string): void {
|
||||
private sink(session: SessionFace, text: string, mode: InputSubmitMode): void {
|
||||
if (text === '') return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
shell?.commitSend()
|
||||
void session.prompt([{ type: 'text', text }], 'queue').then(
|
||||
void session.prompt([{ type: 'text', text }], mode).then(
|
||||
(result) => {
|
||||
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* paste-upgrade all answer their bail events this way).
|
||||
*/
|
||||
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import type {
|
||||
ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions,
|
||||
InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
|
||||
@@ -149,7 +150,6 @@ export class InputMachine {
|
||||
dispatch(ev: InputEvent): readonly InputEffect[] {
|
||||
switch (ev.type) {
|
||||
case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange)
|
||||
case 'newline': return this.onNewline(ev.selection)
|
||||
case 'begin-command': return this.onBeginCommand(ev.claim, ev.span)
|
||||
case 'insert-ref': return this.onInsertRef(ev.reference, ev.span)
|
||||
case 'consume-token': return this.onConsumeToken(ev.guard)
|
||||
@@ -162,7 +162,7 @@ export class InputMachine {
|
||||
this.paste = undefined
|
||||
return []
|
||||
}
|
||||
case 'enter': return this.onEnter()
|
||||
case 'enter': return this.onEnter(ev.mode)
|
||||
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
|
||||
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
|
||||
case 'submit-settled': return this.onSubmitSettled(ev)
|
||||
@@ -254,19 +254,6 @@ export class InputMachine {
|
||||
return []
|
||||
}
|
||||
|
||||
/** F1: caret newline as an ordinary machine transaction (execCommand path removed). */
|
||||
private onNewline(selection: EditSelection): InputEffect[] {
|
||||
const { start, end } = selection
|
||||
if (start < 0 || start > end || end > this.draft.length) return []
|
||||
this.pushTxn(selection)
|
||||
this.typingRun = undefined
|
||||
this.reconcile({ start, end, insertedLength: 1 })
|
||||
this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end))
|
||||
this.watchClaim()
|
||||
this.paste = undefined
|
||||
return []
|
||||
}
|
||||
|
||||
/** Span CAS: revision equality (content identity follows) plus bounds sanity. */
|
||||
private casOk(span: TokenSpan): boolean {
|
||||
return span.draftRev === this.draftRev
|
||||
@@ -461,18 +448,18 @@ export class InputMachine {
|
||||
// ---- submit plane ----
|
||||
|
||||
/** Mint the next SubmitAttempt and take the in-flight slot. */
|
||||
private beginAttempt(): SubmitAttempt {
|
||||
private beginAttempt(mode: InputSubmitMode): SubmitAttempt {
|
||||
const controller = new AbortController()
|
||||
this.seq += 1
|
||||
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
|
||||
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode }
|
||||
this.inflight = { attempt, controller }
|
||||
return attempt
|
||||
}
|
||||
|
||||
private onEnter(): InputEffect[] {
|
||||
private onEnter(mode: InputSubmitMode): InputEffect[] {
|
||||
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
|
||||
if (this.phase === 'claimed' && this.claim !== undefined) {
|
||||
const attempt = this.beginAttempt()
|
||||
const attempt = this.beginAttempt(mode)
|
||||
this.phase = 'submitting'
|
||||
this.paste = undefined
|
||||
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
|
||||
@@ -481,11 +468,11 @@ export class InputMachine {
|
||||
if (trimmed === '') return []
|
||||
this.paste = undefined
|
||||
if (trimmed.startsWith('/')) {
|
||||
const attempt = this.beginAttempt()
|
||||
const attempt = this.beginAttempt(mode)
|
||||
this.phase = 'adjudicating'
|
||||
return [{ type: 'adjudicate', attempt, draft: this.draft }]
|
||||
}
|
||||
return [{ type: 'default-sink', draft: this.draft }]
|
||||
return [{ type: 'default-sink', draft: this.draft, mode }]
|
||||
}
|
||||
|
||||
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
|
||||
@@ -506,7 +493,7 @@ export class InputMachine {
|
||||
this.inflight = undefined
|
||||
this.phase = 'plain'
|
||||
return outcome === undefined
|
||||
? [{ type: 'default-sink', draft: attempt.draftSnapshot }]
|
||||
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }]
|
||||
: []
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Browser-local Composer submission policy. It owns the persisted busy-Enter
|
||||
* preference and resolves keyboard gestures into queue/steer delivery modes;
|
||||
* Host and Agent keep the actual delivery-window authority.
|
||||
*/
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
|
||||
} from '../contract/composer-submission.ts'
|
||||
|
||||
/** localStorage key holding the busy-Enter preference. */
|
||||
export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter'
|
||||
|
||||
/** Default preserves Enter-as-Queue for running conversations. */
|
||||
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
|
||||
|
||||
/**
|
||||
* Persisted policy used by both the composer inject face and its Settings row.
|
||||
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
|
||||
* submission into the next waking Queue item.
|
||||
*/
|
||||
export class ComposerSubmissionPolicy {
|
||||
/** Reactive preference source for the Settings row. */
|
||||
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
|
||||
|
||||
/**
|
||||
* Resolve one keyboard gesture without changing state.
|
||||
* @param running - whether the addressed agent currently reports busy.
|
||||
* @param gesture - plain Enter or the Cmd/Ctrl-accelerated chord.
|
||||
* @param steeringAvailable - whether this session transport supports steering.
|
||||
* @returns Queue outside steer-capable busy state; otherwise the preferred mode or its opposite.
|
||||
*/
|
||||
resolve(
|
||||
running: boolean,
|
||||
gesture: ComposerSubmitGesture,
|
||||
steeringAvailable: boolean,
|
||||
): InputSubmitMode {
|
||||
if (!running || !steeringAvailable) return 'queue'
|
||||
const preferred = this.busyEnter.getSnapshot()
|
||||
if (gesture === 'enter') return preferred
|
||||
return preferred === 'queue' ? 'steer' : 'queue'
|
||||
}
|
||||
|
||||
/**
|
||||
* Change and persist the plain-Enter behavior used during busy state.
|
||||
* @param behavior - Queue or Steer.
|
||||
*/
|
||||
setBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (this.busyEnter.getSnapshot() === behavior) return
|
||||
this.busyEnter.set(behavior)
|
||||
persistBusyEnter(behavior)
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a valid preference; unavailable or corrupt storage uses Queue. */
|
||||
function restoreBusyEnter(): BusyEnterBehavior {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
let stored: string | null
|
||||
try {
|
||||
stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY)
|
||||
} catch {
|
||||
// Storage access can fail in privacy modes; the default remains usable.
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
if (stored === 'queue' || stored === 'steer') return stored
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
|
||||
/** Persist a preference when browser storage is available. */
|
||||
function persistBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior)
|
||||
} catch {
|
||||
// A storage failure makes the preference session-only; input stays usable.
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ export const zh = {
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'settings.enter.title': '繁忙时 Enter 键行为',
|
||||
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
|
||||
'settings.enter.queue': '排队发送',
|
||||
'settings.enter.steer': '插话发送',
|
||||
'access.confirm.title': '确认启用 Full access?',
|
||||
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
@@ -89,8 +93,11 @@ export const zh = {
|
||||
'queue.save': '保存排队消息',
|
||||
'queue.cancelEdit': '取消编辑',
|
||||
'queue.remove': '删除排队消息',
|
||||
'queue.steer': '插话发送',
|
||||
'queue.steer.unavailable': '仅运行中可插话发送',
|
||||
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
|
||||
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
|
||||
'queue.steerFailed': '插话发送失败,请重试。',
|
||||
'terminal.signal': '信号 {signal}',
|
||||
'terminal.exitCode': '退出码 {code}',
|
||||
'terminal.running': '运行中',
|
||||
@@ -123,6 +130,10 @@ export const en = {
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'settings.enter.title': 'Enter behavior while busy',
|
||||
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
|
||||
'settings.enter.queue': 'Queue',
|
||||
'settings.enter.steer': 'Steer',
|
||||
'access.confirm.title': 'Enable Full access?',
|
||||
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
|
||||
'access.confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
@@ -189,8 +200,11 @@ export const en = {
|
||||
'queue.save': 'Save queued message',
|
||||
'queue.cancelEdit': 'Cancel editing',
|
||||
'queue.remove': 'Remove queued message',
|
||||
'queue.steer': 'Steer queued message',
|
||||
'queue.steer.unavailable': 'Steering is available only while the agent is running',
|
||||
'queue.editFailed': 'Edit failed: this message may have already started sending.',
|
||||
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
|
||||
'queue.steerFailed': 'Steering failed. Try again.',
|
||||
'terminal.signal': 'signal {signal}',
|
||||
'terminal.exitCode': 'exit code {code}',
|
||||
'terminal.running': 'Running',
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
// The 'conversation.input.dock' SlotMap declaration lives in
|
||||
// ../contract/slots.ts beside the other input-region slots.
|
||||
import type { Context } from 'cordis'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import { NS } from '../locales.ts'
|
||||
@@ -29,7 +29,10 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
|
||||
* collapsible count header; an empty queue renders nothing.
|
||||
*/
|
||||
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
|
||||
const queue = useSession(s => s.queue)
|
||||
const inbox = useSession(s => s.queue)
|
||||
const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
|
||||
const running = useSession(s => s.running)
|
||||
const queueMutable = useSession(s => s.subagent === null)
|
||||
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
|
||||
const [busy, setBusy] = useState<QueueItemId | null>(null)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
@@ -37,12 +40,12 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length === 0 && !collapsed) setCollapsed(true)
|
||||
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
|
||||
}, [collapsed, editing, queue])
|
||||
if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null)
|
||||
}, [collapsed, editing, queue, queueMutable])
|
||||
|
||||
if (queue.length === 0) return null
|
||||
|
||||
const interactionActive = editing !== null || busy !== null
|
||||
const interactionActive = queueMutable && (editing !== null || busy !== null)
|
||||
const expanded = !collapsed || interactionActive
|
||||
const listVisible = queue.length === 1 || expanded
|
||||
|
||||
@@ -114,7 +117,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
/>
|
||||
)
|
||||
: <span className={css.preview}>{row.preview}</span>}
|
||||
<div className={css.actions}>
|
||||
{queueMutable && <div className={css.actions}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<>
|
||||
@@ -170,9 +173,25 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.steer')}
|
||||
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
|
||||
disabled={busy !== null || !running}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'steer' },
|
||||
t('queue.steerFailed'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconSendOutline16 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Queue read face for the InputState.queue projection (frozen contract in
|
||||
* ../input/contract.ts): a uSES-compatible observable over one session's
|
||||
* queue rows. The Session snapshot already keeps the queue array
|
||||
* transient inbox rows. The Session snapshot already keeps the queue array
|
||||
* reference-stable across unrelated snapshot swaps, so this is a pure
|
||||
* projection — no second store, no copy.
|
||||
*/
|
||||
@@ -9,7 +9,7 @@ import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-ru
|
||||
import type { QueuedMessage } from '../input/contract.ts'
|
||||
|
||||
/**
|
||||
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
|
||||
* Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot).
|
||||
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally
|
||||
* identical.
|
||||
|
||||
@@ -31,10 +31,10 @@ export interface IConversation {
|
||||
*/
|
||||
send(text: string): Promise<void>
|
||||
/**
|
||||
* Apply one operation to a pending queue occurrence.
|
||||
* Apply one edit, remove, or strict steer operation to a pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - edit or remove operation.
|
||||
* @returns completion; business failures reject.
|
||||
* @param action - requested queue operation.
|
||||
* @returns completion; converged strict-steer races resolve, while other failures reject.
|
||||
*/
|
||||
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
|
||||
/**
|
||||
@@ -82,6 +82,10 @@ export class ConversationService extends Service implements IConversation {
|
||||
const session = this.scopedSession('updateQueue')
|
||||
const result = await session.updateQueue(itemId, action)
|
||||
if (!result.ok) {
|
||||
if (
|
||||
action.kind === 'steer'
|
||||
&& (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found')
|
||||
) return
|
||||
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Composer Enter preference row: title/description plus selector pill. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** General Settings row for the Composer's busy-state Enter preference. */
|
||||
import { useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { BusyEnterBehavior } from '../contract/composer-submission.ts'
|
||||
import type { ConversationKey } from '../locales.ts'
|
||||
import css from './EnterBehaviorRow.module.css'
|
||||
|
||||
/** Registration-side preference face. */
|
||||
export interface EnterBehaviorRowInjected {
|
||||
hooks: {
|
||||
/** Persisted busy-state preference bound as useBusyEnter. */
|
||||
busyEnter: SnapshotStore<BusyEnterBehavior>
|
||||
}
|
||||
/** Change the busy-state plain-Enter behavior. */
|
||||
setBusyEnter: (behavior: BusyEnterBehavior) => void
|
||||
}
|
||||
|
||||
/** Full Settings-row props. */
|
||||
export type EnterBehaviorRowProps =
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'conversation'>
|
||||
& InjectFace<EnterBehaviorRowInjected>
|
||||
|
||||
const OPTIONS: readonly {
|
||||
id: BusyEnterBehavior
|
||||
label: ConversationKey
|
||||
}[] = [
|
||||
{ id: 'queue', label: 'settings.enter.queue' },
|
||||
{ id: 'steer', label: 'settings.enter.steer' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Render the busy-state Enter behavior selector.
|
||||
* @param props - composed Settings slot props.
|
||||
* @returns the preference row.
|
||||
*/
|
||||
export function EnterBehaviorRow({ useBusyEnter, setBusyEnter, t }: EnterBehaviorRowProps) {
|
||||
const behavior = useBusyEnter(value => value)
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedLabel = behavior === 'queue' ? 'settings.enter.queue' : 'settings.enter.steer'
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('settings.enter.title')}</div>
|
||||
<div className={css.desc}>{t('settings.enter.description')}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
|
||||
selectedId={behavior}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
setBusyEnter(id as BusyEnterBehavior)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{t(selectedLabel)}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export interface InputBarError {
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
|
||||
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
|
||||
renderSlot, useNotices, useLexicon, useMenuLauncher,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
}: InputBarProps) {
|
||||
@@ -178,23 +178,14 @@ export function InputBar({
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Newline as a machine transaction (the machine owns undo history; an
|
||||
// execCommand write would fork a second, browser-owned history).
|
||||
e.preventDefault()
|
||||
if (!machineBusy && !locked) {
|
||||
const el = e.currentTarget
|
||||
const sel = selectionOf(el)
|
||||
keyboard.newline(sel)
|
||||
const caret = sel.start + 1
|
||||
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
|
||||
}
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
if (e.repeat) return // held-down Enter must not machine-gun sends
|
||||
if (locked || machineBusy) return
|
||||
inputActions.submit()
|
||||
keyboard.submit(resolveSubmitMode(
|
||||
running,
|
||||
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
|
||||
subagent === null,
|
||||
))
|
||||
}
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
|
||||
14px radius, status icons + secondary item labels. It shares the composer
|
||||
card geometry and adds the dock inset on both sides. */
|
||||
/* Todo strip in the composer context stack (Figma 1236:32276): tip surface,
|
||||
14px radius, status icons + secondary item labels. Its visible card aligns
|
||||
with the GoalBar and the Queue panel inside their shared dock column. */
|
||||
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
@@ -12,11 +12,15 @@
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
max-width: calc(
|
||||
var(--dsh-composer-card-max-width) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
|
||||
@@ -138,10 +138,10 @@ export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the plan strip between the goal and queue entries (order 10).
|
||||
* Register the plan strip before the goal and queue entries (order 0).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ async function bench() {
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'settings.general.item': { kind: 'list', scope: 'root' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
@@ -85,6 +86,7 @@ describe('apply wiring', () => {
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
// declaration (the empty-state occupant is gone).
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -112,6 +114,7 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
|
||||
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -102,18 +102,28 @@ describe('MessageItem arms', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
})
|
||||
|
||||
it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => {
|
||||
it('consumed steering renders copy and branch actions without a badge', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const fork = vi.fn()
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
|
||||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||||
} as never}
|
||||
onFork={fork}
|
||||
/>,
|
||||
)
|
||||
expect(view.queryByText('插话')).toBeNull()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('steer!')
|
||||
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(fork).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
|
||||
@@ -320,6 +320,42 @@ describe('ToolRow', () => {
|
||||
})
|
||||
|
||||
describe('ThinkRow', () => {
|
||||
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
const summary = view.getByText('Newest reasoning tokens')
|
||||
Object.defineProperties(summary, {
|
||||
scrollWidth: { configurable: true, value: 300 },
|
||||
clientWidth: { configurable: true, value: 100 },
|
||||
})
|
||||
|
||||
view.rerender(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
expect(summary.scrollLeft).toBe(200)
|
||||
expect(summary.getAttribute('data-follow-end')).toBe('true')
|
||||
|
||||
view.rerender(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('Inspect the session')).toBeTruthy()
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
expect(summary.hasAttribute('data-follow-end')).toBe(false)
|
||||
})
|
||||
|
||||
it('expands from either Think or the reasoning summary', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
|
||||
@@ -251,6 +251,87 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const pending = {
|
||||
id: 'steer-occurrence' as never,
|
||||
messageId: 'steer-message' as never,
|
||||
placement: 'steering' as const,
|
||||
content: [{ type: 'text' as const, text: 'interrupt now' }],
|
||||
preview: 'interrupt now',
|
||||
text: 'interrupt now',
|
||||
}
|
||||
const queued = {
|
||||
id: 'queued-occurrence' as never,
|
||||
messageId: 'queued-message' as never,
|
||||
placement: 'queued' as const,
|
||||
content: [{ type: 'text' as const, text: 'later' }],
|
||||
preview: 'later',
|
||||
text: 'later',
|
||||
}
|
||||
const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull()
|
||||
expect(view.queryByText('later')).toBeNull()
|
||||
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
|
||||
expect(pendingBubble).not.toBeNull()
|
||||
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('interrupt now')
|
||||
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
queue: [queued],
|
||||
nodes: [
|
||||
assistant(1, 'working'),
|
||||
{
|
||||
kind: 'steering', messageId: pending.messageId,
|
||||
seq: 2, time: 2_000, turn: 1,
|
||||
content: [{ type: 'text', text: 'interrupt now' }], source: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(2)
|
||||
fireEvent.click(branchButtons[1]!)
|
||||
expect(h.forkAt).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
|
||||
const pending = {
|
||||
id: 'steer-occurrence-later' as never,
|
||||
messageId: 'shared-steer-message' as never,
|
||||
placement: 'steering' as const,
|
||||
content: [{ type: 'text' as const, text: 'same steering' }],
|
||||
preview: 'same steering',
|
||||
text: 'same steering',
|
||||
}
|
||||
const h = makeHarness({
|
||||
queue: [pending],
|
||||
nodes: [{
|
||||
kind: 'steering', messageId: pending.messageId,
|
||||
seq: 2, time: 2_000, turn: 1,
|
||||
content: pending.content, source: null,
|
||||
}],
|
||||
running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
expect(view.getAllByText('same steering')).toHaveLength(2)
|
||||
expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('animates only the latest unresolved model retry', () => {
|
||||
const retryNode = retry(2)
|
||||
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx'
|
||||
import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx'
|
||||
import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
function emptySessions() {
|
||||
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) })
|
||||
const props: EnterBehaviorRowProps = {
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
|
||||
setBusyEnter,
|
||||
t: makeTranslate(en),
|
||||
}
|
||||
render(<EnterBehaviorRow {...props} />)
|
||||
return { policy, setBusyEnter }
|
||||
}
|
||||
|
||||
describe('EnterBehaviorRow', () => {
|
||||
it('explains the busy-only scope and shows Queue by default', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Enter behavior while busy')).toBeDefined()
|
||||
expect(screen.getByText('Busy only; Cmd/Ctrl+Enter uses the other behavior')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /Queue/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('selects Steer, follows later preference changes, and closes outside', () => {
|
||||
const b = mount()
|
||||
const trigger = screen.getByRole('button', { name: /Queue/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Steer' }))
|
||||
expect(b.setBusyEnter).toHaveBeenCalledWith('steer')
|
||||
expect(screen.getByRole('button', { name: /Steer/ })).toBeDefined()
|
||||
|
||||
act(() => { b.policy.setBusyEnter('queue') })
|
||||
const queueTrigger = screen.getByRole('button', { name: /Queue/ })
|
||||
fireEvent.click(queueTrigger)
|
||||
expect(screen.getByRole('menuitem', { name: 'Steer' })).toBeDefined()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Steer' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
|
||||
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
|
||||
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
|
||||
// semantics (input stays free; primary turns stop), the machine pending lock,
|
||||
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
|
||||
|
||||
@@ -53,6 +53,7 @@ interface BenchOptions {
|
||||
leftItems?: React.ReactNode
|
||||
rightItems?: React.ReactNode
|
||||
commandMenuOpen?: boolean
|
||||
busyEnter?: 'queue' | 'steer'
|
||||
toggleCommandMenu?: (selection: { start: number; end: number }) => void
|
||||
}
|
||||
|
||||
@@ -107,6 +108,11 @@ function bench(over?: BenchOptions) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) => {
|
||||
if (!running || !steeringAvailable) return 'queue'
|
||||
const preferred = over?.busyEnter ?? 'queue'
|
||||
return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue'
|
||||
},
|
||||
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
@@ -137,7 +143,7 @@ describe('Enter semantics', () => {
|
||||
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
|
||||
const { textarea, sink } = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('hello')
|
||||
expect(sink).toHaveBeenCalledWith('hello', 'queue')
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
|
||||
expect(sink).toHaveBeenCalledTimes(1)
|
||||
const empty = bench({ draft: ' ' })
|
||||
@@ -159,12 +165,18 @@ describe('Enter semantics', () => {
|
||||
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
|
||||
})
|
||||
|
||||
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
|
||||
const { textarea, shell, sink } = bench({ draft: 'hello' })
|
||||
textarea.setSelectionRange(5, 5)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(shell.snapshot.draft).toBe('hello\n')
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
|
||||
const idle = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(idle.sink).toHaveBeenCalledWith('hello', 'queue')
|
||||
|
||||
const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
|
||||
fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer')
|
||||
|
||||
const busyMeta = bench({ running: true, draft: 'steer with cmd' })
|
||||
fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
|
||||
})
|
||||
|
||||
it('platform undo/redo chords route to the machine, never the browser stack', () => {
|
||||
@@ -205,12 +217,28 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect(textarea.disabled).toBe(false) // running no longer locks
|
||||
fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2')
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
|
||||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||||
fireEvent.click(button)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('running plain Enter follows the busy-state Steer preference', () => {
|
||||
const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('直接插话', 'steer')
|
||||
})
|
||||
|
||||
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
|
||||
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
|
||||
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue')
|
||||
|
||||
const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
|
||||
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
|
||||
})
|
||||
|
||||
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
|
||||
const { button, sink, stop } = bench({
|
||||
running: true,
|
||||
@@ -226,7 +254,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
})
|
||||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('后续消息')
|
||||
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
|
||||
const empty = bench({
|
||||
@@ -243,6 +271,24 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps both running subagent Enter gestures on Queue transport', () => {
|
||||
const subagent = {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable' as const,
|
||||
},
|
||||
parentAvailable: true,
|
||||
}
|
||||
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
|
||||
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
|
||||
expect(plain.sink).toHaveBeenCalledWith('plain', 'queue')
|
||||
|
||||
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
|
||||
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue')
|
||||
})
|
||||
|
||||
it('disabled (session removed) locks the textarea and chrome', () => {
|
||||
const { textarea, view } = bench({ disabled: true })
|
||||
expect(textarea.disabled).toBe(true)
|
||||
@@ -253,7 +299,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
const { button, sink } = bench({ draft: 'go' })
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('go')
|
||||
expect(sink).toHaveBeenCalledWith('go', 'queue')
|
||||
const empty = bench()
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
})
|
||||
|
||||
@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
|
||||
}
|
||||
|
||||
/** Drive plain → adjudicating and hand back the minted attempt. */
|
||||
function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt {
|
||||
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
|
||||
m.dispatch({ type: 'draft-changed', draft })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
const fx = m.dispatch({ type: 'enter', mode })
|
||||
return effectAt(fx, 0, 'adjudicate').attempt
|
||||
}
|
||||
|
||||
@@ -52,35 +52,42 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
|
||||
m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
|
||||
m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
|
||||
m.dispatch({ type: 'draft-changed', draft: claim.token + args })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
|
||||
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
|
||||
}
|
||||
|
||||
function staleAttempt(): SubmitAttempt {
|
||||
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' }
|
||||
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
|
||||
}
|
||||
|
||||
describe('input-machine: plain × enter', () => {
|
||||
it('empty and whitespace-only drafts produce nothing', () => {
|
||||
const m = new InputMachine()
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('non-command text falls to the default sink', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
|
||||
expect(m.dispatch({ type: 'enter' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'hello world' }])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'queue' }])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('retains an explicit steer mode on the default sink effect', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: 'steer now' })
|
||||
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'steer now', mode: 'steer' }])
|
||||
})
|
||||
|
||||
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
|
||||
const eff = effectAt(fx, 0, 'adjudicate')
|
||||
expect(eff.draft).toBe('/goal x')
|
||||
expect(eff.attempt.draftSnapshot).toBe('/goal x')
|
||||
@@ -91,14 +98,14 @@ describe('input-machine: plain × enter', () => {
|
||||
it('leading is judged after trim including newlines', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
|
||||
expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate')
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
|
||||
})
|
||||
|
||||
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
|
||||
expect(m.dispatch({ type: 'enter' }))
|
||||
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
|
||||
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,9 +133,9 @@ describe('input-machine: adjudication outcomes', () => {
|
||||
|
||||
it('undefined outcome falls back to the default sink', () => {
|
||||
const m = new InputMachine()
|
||||
const attempt = enterAdjudicating(m, '/unknown thing')
|
||||
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
|
||||
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
|
||||
.toEqual([{ type: 'default-sink', draft: '/unknown thing' }])
|
||||
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
@@ -152,7 +159,7 @@ describe('input-machine: adjudication outcomes', () => {
|
||||
it('enter is a no-op while adjudicating (pending lock)', () => {
|
||||
const m = new InputMachine()
|
||||
enterAdjudicating(m, '/goal x')
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.state.phase).toBe('adjudicating')
|
||||
})
|
||||
|
||||
@@ -344,31 +351,6 @@ describe('input-machine: occurrence reconciliation on draft edits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('input-machine: newline transaction (F1)', () => {
|
||||
it('inserts \\n at the caret and shifts trailing occurrences', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
|
||||
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
|
||||
expect(m.state.draft).toBe(`ab\n ${P} `)
|
||||
expect(m.state.occurrences[0]?.offset).toBe(4)
|
||||
m.dispatch({ type: 'undo' })
|
||||
expect(m.state.draft).toBe(`ab ${P} `)
|
||||
})
|
||||
|
||||
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '/go' })
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([])
|
||||
expect(m.state.phase).toBe('claimed')
|
||||
m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } })
|
||||
expect(m.state.draft).toBe('\n/goal ')
|
||||
expect(m.state.phase).toBe('plain')
|
||||
expect(m.state.claim).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('input-machine: consume-token guards', () => {
|
||||
it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
|
||||
const m = new InputMachine()
|
||||
@@ -587,7 +569,7 @@ describe('input-machine: paste plane', () => {
|
||||
|
||||
const b = new InputMachine()
|
||||
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
|
||||
b.dispatch({ type: 'enter' })
|
||||
b.dispatch({ type: 'enter', mode: 'queue' })
|
||||
expect(b.state.paste).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -755,7 +737,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
|
||||
const m = new InputMachine()
|
||||
enterSubmitting(m, 'goal', 'x')
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
|
||||
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
|
||||
})
|
||||
@@ -768,7 +750,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
|
||||
const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
|
||||
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
|
||||
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
|
||||
expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
|
||||
expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
|
||||
@@ -809,7 +791,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
const m = new InputMachine()
|
||||
const { attempt: first } = enterSubmitting(m, 'goal', 'x')
|
||||
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
|
||||
const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
|
||||
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
|
||||
expect(second.seq).not.toBe(first.seq)
|
||||
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
|
||||
expect(m.state.phase).toBe('submitting')
|
||||
|
||||
@@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: vi.fn(),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
@@ -88,7 +89,7 @@ describe('matrix row: plain', () => {
|
||||
fireEvent.change(textarea, { target: { value: '普通消息' } })
|
||||
expect(shell.snapshot.claim).toBeUndefined()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('普通消息')
|
||||
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
})
|
||||
@@ -187,7 +188,7 @@ describe('matrix row: locked (session disabled)', () => {
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队')
|
||||
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: (selection) => {
|
||||
const snapshot = shell.snapshot
|
||||
controller.toggleSource('command', {
|
||||
@@ -235,7 +236,7 @@ describe('scenario D: execute-kind /compact', () => {
|
||||
act(() => { b2.shell.setDraft('/compact 现在') })
|
||||
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
|
||||
// execute with trailing → matchEnter answers undefined → default sink.
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') })
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
|
||||
expect(b2.executed).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -289,7 +290,7 @@ describe('scenario I: unknown /xyz + enter', () => {
|
||||
const b = await bench()
|
||||
act(() => { b.shell.setDraft('/xyz 干点啥') })
|
||||
fireEvent.keyDown(b.textarea, { key: 'Enter' })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
|
||||
expect(b.shell.snapshot.phase).toBe('plain')
|
||||
expect(b.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* QueueDock rendering and operations: authoritative rows, inline editing,
|
||||
* collapse state, removal, failure notices, and live retirement.
|
||||
* collapse state, removal, strict steering, failure notices, and live retirement.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
@@ -23,7 +23,11 @@ const SID = 's1' as SessionId
|
||||
const iid = (id: string): QueueItemId => id as QueueItemId
|
||||
|
||||
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
|
||||
return { id: iid(id), preview, text }
|
||||
return {
|
||||
id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
|
||||
content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
|
||||
preview, text,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
@@ -85,6 +89,14 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('leaves pending steering to the conversation flow', () => {
|
||||
const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
|
||||
const snap = snapshotWith([steering])
|
||||
const source = liveSession(snap)
|
||||
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
|
||||
const single = snapshotWith([row('i-1', 'one')])
|
||||
const source = liveSession(single)
|
||||
@@ -187,10 +199,10 @@ describe('QueueDock', () => {
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
expect([...container.querySelectorAll('li')].map(item => item.textContent))
|
||||
.toEqual(['第一条排队消息', 'image [image]'])
|
||||
expect(container.querySelectorAll('button')).toHaveLength(5)
|
||||
expect(container.querySelectorAll('button')).toHaveLength(7)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
|
||||
expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
|
||||
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
|
||||
@@ -273,6 +285,68 @@ describe('QueueDock', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('strictly steers complete row content only while the agent is running', async () => {
|
||||
const running = snapshotWith([row('i-steer', null, 'image [image]')])
|
||||
const source = liveSession(running)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const rendered = render(
|
||||
<QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
const button = rendered.getByLabelText('插话发送')
|
||||
expect(button).toHaveProperty('disabled', false)
|
||||
fireEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
|
||||
})
|
||||
|
||||
act(() => { source.push({ ...running, running: false }) })
|
||||
expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
|
||||
expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
|
||||
})
|
||||
|
||||
it('renders a session-backed subagent Queue without unsupported actions', () => {
|
||||
const snap = {
|
||||
...snapshotWith([row('i-subagent', 'pending child follow-up')]),
|
||||
subagent: {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable' as const,
|
||||
},
|
||||
parentAvailable: true,
|
||||
},
|
||||
}
|
||||
const source = liveSession(snap)
|
||||
const view = render(
|
||||
<QueueDock {...kitFor(snap)} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
expect(view.getByText('pending child follow-up')).toBeTruthy()
|
||||
expect(view.queryByLabelText('编辑排队消息')).toBeNull()
|
||||
expect(view.queryByLabelText('删除排队消息')).toBeNull()
|
||||
expect(view.queryByLabelText('插话发送')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the row and reports a genuine steer failure', async () => {
|
||||
const snap = snapshotWith([row('i-steer-race', 'pending steer')])
|
||||
const source = liveSession(snap)
|
||||
const notify = vi.fn()
|
||||
const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
|
||||
const { getByLabelText, getByText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('插话发送'))
|
||||
await waitFor(() => {
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'插话发送失败,请重试。',
|
||||
)
|
||||
})
|
||||
expect(getByText('pending steer')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
|
||||
const snap = snapshotWith([row('i-race', 'pending')])
|
||||
const source = liveSession(snap)
|
||||
|
||||
@@ -50,6 +50,29 @@ describe('ConversationService', () => {
|
||||
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
||||
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
|
||||
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'internal', message: 'broken', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' }))
|
||||
.rejects.toThrow('conversation.updateQueue failed: internal: broken')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('treats strict-steer races as converged Queue delivery', async () => {
|
||||
const b = await bench()
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined()
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined()
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
|
||||
} as never)
|
||||
await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' }))
|
||||
.rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ function mount(
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
resolveSubmitMode={() => 'queue'}
|
||||
toggleCommandMenu={vi.fn()}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
@@ -220,7 +221,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
fireEvent.change(box, { target: { value: 'ordinary revised' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised')
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
|
||||
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(b.view.queryByText('Root')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
|
||||
} from '../src/client/input/submission-policy.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('ComposerSubmissionPolicy', () => {
|
||||
it('defaults to Queue and only applies the preference while running', () => {
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
expect(policy.busyEnter.getSnapshot()).toBe(DEFAULT_BUSY_ENTER_BEHAVIOR)
|
||||
expect(policy.resolve(false, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
|
||||
expect(policy.resolve(true, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(true, 'accelerated', true)).toBe('steer')
|
||||
expect(policy.resolve(true, 'enter', false)).toBe('queue')
|
||||
expect(policy.resolve(true, 'accelerated', false)).toBe('queue')
|
||||
|
||||
const changed = vi.fn()
|
||||
policy.busyEnter.subscribe(changed)
|
||||
policy.setBusyEnter('steer')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect(policy.resolve(true, 'enter', true)).toBe('steer')
|
||||
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
|
||||
expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer')
|
||||
})
|
||||
|
||||
it('restores a valid preference and leaves an identical write untouched', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer')
|
||||
const write = vi.spyOn(Storage.prototype, 'setItem')
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
policy.setBusyEnter('steer')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
write.mockRestore()
|
||||
})
|
||||
|
||||
it('uses Queue for invalid, unavailable, or unreadable storage', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid')
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => { throw new Error('blocked') },
|
||||
setItem: vi.fn(),
|
||||
})
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
})
|
||||
|
||||
it('keeps the in-memory preference when persistence throws', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('quota') },
|
||||
})
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
policy.setBusyEnter('steer')
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
})
|
||||
@@ -99,12 +99,12 @@ describe('TodoDock', () => {
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('registers between the goal and queue entries', () => {
|
||||
it('registers before the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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/client/ui-goal/README.md
|
||||
README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46
|
||||
README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448
|
||||
README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33
|
||||
README.zh.md: c2474fc6ef8d0c990da4b4eaff79d56baf3180cf
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
|
||||
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/* GoalBar: the first standalone card in the composer context stack (Figma
|
||||
9:939). Its 752px column matches Todo and the Queue panel. */
|
||||
/* GoalBar: the second standalone card in the composer context stack (Figma
|
||||
1236:32276). Its 752px column matches Todo and the Queue panel. */
|
||||
|
||||
.dock {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 44px;
|
||||
width: calc(
|
||||
100% -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.bar {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function apply(ctx: ClientContext): void {
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 0,
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 0, locale: 'goal' })
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
|
||||
@@ -2033,11 +2033,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'InboxAction',
|
||||
declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n};',
|
||||
declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'InboxActionResult',
|
||||
declaration: 'export type InboxActionResult = \'applied\' | \'not-found\';',
|
||||
declaration: 'export type InboxActionResult = \'applied\' | \'not-found\' | \'steer-unavailable\';',
|
||||
},
|
||||
{
|
||||
name: 'InboxItemId',
|
||||
|
||||
@@ -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/core/agent-loop/README.md
|
||||
README.md: f146ea3d2cc379303514286c5cfd2833397f23ed
|
||||
README.zh.md: 767fbd420709be05e9a8f85dc8ec6fe03ae6aa66
|
||||
README.md: 2ce85071c4b7408adb4ee05291c499ec642be114
|
||||
README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced
|
||||
|
||||
@@ -59,7 +59,7 @@ The unified `send()` primitive routes content and source by (`target` × `wakeup
|
||||
|
||||
`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it.
|
||||
|
||||
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and strict steer transfers the immutable message into an open next-step window as a new steering occurrence. A closed window returns `steer-unavailable` without mutation; pending steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
|
||||
### Loop lifecycle (`agent.ts`)
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ interface Config {
|
||||
|
||||
`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。
|
||||
|
||||
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;严格 steering 会把不可变消息作为新的 steering 单次入队项转移到开放的 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不做任何变更;待处理 steering 和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
|
||||
### 循环生命周期(`agent.ts`)
|
||||
|
||||
|
||||
@@ -256,6 +256,27 @@ export class ReactLoopAgent implements Agent {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
|
||||
return 'applied'
|
||||
}
|
||||
case 'steer': {
|
||||
if (!this.acceptsNextStep) return 'steer-unavailable'
|
||||
this.queued.splice(queuedIndex, 1)
|
||||
const item: InboxItem = Object.freeze({
|
||||
id: InboxItemId(randomUUID()),
|
||||
message: pending.item.message,
|
||||
placement: 'steering',
|
||||
})
|
||||
this.outbox.push({
|
||||
message: item.message,
|
||||
steering: true,
|
||||
item,
|
||||
...pending.delivery === undefined ? {} : { delivery: pending.delivery },
|
||||
})
|
||||
// Publish the replacement only after it is owned by the outbox. Its
|
||||
// enqueue precedes the old occurrence's discard so reentrant
|
||||
// cancellation can terminally account for both occurrences.
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
|
||||
return 'applied'
|
||||
}
|
||||
default:
|
||||
/* v8 ignore next -- InboxAction is a closed discriminated union. */
|
||||
return assertNever(action)
|
||||
|
||||
@@ -156,6 +156,106 @@ describe('addressable inbox operations', () => {
|
||||
: ''))
|
||||
.toEqual(['keep me'])
|
||||
})
|
||||
|
||||
it('strictly transfers a queued occurrence into the open turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) enqueued.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
const receipt = agent.steer(createUserMessage({
|
||||
content: [{ type: 'text', text: 'steer this message' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await entered.promise
|
||||
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(steering.id).not.toBe(queued.id)
|
||||
expect(steering.message).toBe(queued.message)
|
||||
expect(discarded).toEqual([queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.flatMap(event =>
|
||||
event.type === 'steering/message' ? [event.data.message] : [],
|
||||
)).toEqual([queued.message])
|
||||
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 })
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
|
||||
})
|
||||
|
||||
it('keeps a queued occurrence when the next-step window is closed', () => {
|
||||
const ctx = new Context()
|
||||
const session = new Session(SessionId('queue-to-steer-closed'))
|
||||
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
|
||||
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
|
||||
|
||||
agent.send(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
|
||||
{ target: 'next-turn', wakeup: false },
|
||||
)
|
||||
const queued = enqueued[0]!
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
|
||||
expect(discarded).toEqual([])
|
||||
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
|
||||
})
|
||||
|
||||
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
enqueued.push(item)
|
||||
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
await entered.promise
|
||||
send(agent, 'cancel during conversion')
|
||||
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(discarded).toEqual([steering, queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant replay provenance', () => {
|
||||
|
||||
@@ -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/core/agent/README.md
|
||||
README.md: 5f9fdc44794a562c2b0da39e1a43a01aa19c451b
|
||||
README.zh.md: f088b8c354f0aec1b51d7ff8ae706ddc49265a20
|
||||
README.md: 98421aa6de3d6778702665854ed723507e933028
|
||||
README.zh.md: bfc8d68a9656a29a809de0848986e4ee9eb3fe7c
|
||||
|
||||
@@ -62,7 +62,7 @@ The handle every plugin programs against:
|
||||
|
||||
- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session.
|
||||
- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`.
|
||||
- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or strictly steers one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Strict steer requires `acceptsNextStep`, ends the queued occurrence, and accepts the same immutable message as a new steering occurrence with a new `InboxItemId`; a closed window returns `steer-unavailable` without mutation. Pending steering and claimed occurrences return `not-found`.
|
||||
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
|
||||
- `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it.
|
||||
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
|
||||
|
||||
@@ -62,7 +62,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
|
||||
- `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。
|
||||
- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。
|
||||
- `agent.updateInbox(itemId, action)`:同步编辑、移除一个仍处于待处理状态的 queued 入队项,或对其执行严格 steering。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。严格 steering 要求 `acceptsNextStep` 为 true;它会结束 queued 单次入队项,并把同一条不可变消息接受为新的 steering 单次入队项,后者使用新的 `InboxItemId`。窗口关闭时返回 `steer-unavailable`,且不做任何变更。待处理 steering 和已被认领的项会返回 `not-found`。
|
||||
- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
|
||||
- `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。
|
||||
- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。
|
||||
|
||||
@@ -54,9 +54,10 @@ export interface InboxItem {
|
||||
export type InboxAction =
|
||||
| { readonly kind: 'edit'; readonly content: ContentBlock[] }
|
||||
| { readonly kind: 'remove' }
|
||||
| { readonly kind: 'steer' }
|
||||
|
||||
/** Result of applying an inbox action at the synchronous ownership boundary. */
|
||||
export type InboxActionResult = 'applied' | 'not-found'
|
||||
export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable'
|
||||
|
||||
/** Final admission outcome for one call to {@link Agent.steer}. */
|
||||
export type SteeringOutcome =
|
||||
@@ -209,10 +210,13 @@ export interface Agent {
|
||||
/**
|
||||
* Mutate one still-pending queued occurrence synchronously. Editing preserves
|
||||
* the message identity and queue position; removal publishes its terminal
|
||||
* discard. Steering occurrences and driver-claimed items return `not-found`.
|
||||
* discard. Steer strictly transfers the message into the current next-step
|
||||
* window, or returns `steer-unavailable` without changing the queued
|
||||
* occurrence. Steering occurrences and driver-claimed items return
|
||||
* `not-found`.
|
||||
* @param id - independently addressable queued occurrence.
|
||||
* @param action - edit or remove operation.
|
||||
* @returns whether the pending occurrence was found and updated.
|
||||
* @param action - edit, remove, or strict steer operation.
|
||||
* @returns the applied outcome or the reason no mutation occurred.
|
||||
*/
|
||||
updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult
|
||||
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda
|
||||
README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a
|
||||
README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f
|
||||
README.zh.md: 1f0791c5df7afd4a3479afdd827c4fc148cf8883
|
||||
|
||||
@@ -43,7 +43,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti
|
||||
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token.
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. It defers one context until the tool's final result reaches the loop — typically a nested-dispatch context ferried by a composite tool, or a fresh plugin-sourced instruction minted by a leaf tool (`tool-goal`'s wrap-up) — even when the tool later throws or cancellation wins; it never injects immediately.
|
||||
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute identified `UserMessage` for the loop's post-result FIFO.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
|
||||
@@ -43,7 +43,7 @@ tools:
|
||||
- `ToolExecutionInput`:调用方提供的调用描述:`{ callId, name, arguments, signal, agent?, parent? }`;`signal` 必填且只读,调用方可以将外层执行的不透明 token 作为 `parent` 传入,但绝不能选择新执行自身的 token。
|
||||
- `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。
|
||||
- `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent` 是 `ToolExecutionToken`,而不是执行对象。
|
||||
- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。
|
||||
- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。它把一条上下文推迟到该工具的最终结果抵达循环时——通常是组合工具转运的嵌套分发上下文,也可以是叶子工具铸造的全新插件来源指令(如 `tool-goal` 的收尾注入)——即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。
|
||||
- `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError`;`additionalContexts` 会保留每个通过延迟或 post-execute 加入且带标识的 `UserMessage`,供循环在结果后按 FIFO 顺序处理。
|
||||
- `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。
|
||||
- `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。
|
||||
|
||||
@@ -344,15 +344,18 @@ export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
|
||||
|
||||
/**
|
||||
* Runtime context handed to a tool implementation after the registry has
|
||||
* accepted a {@link ToolExecution}. A composite tool uses
|
||||
* {@link deferContext} to ferry context produced by nested dispatches back to
|
||||
* the outer result; the loop appends it only after the outer `tool/result`.
|
||||
* accepted a {@link ToolExecution}. {@link deferContext} attaches context to
|
||||
* this execution's own result — a composite tool ferries nested-dispatch
|
||||
* context back to the outer result, and a leaf tool may mint a fresh
|
||||
* plugin-sourced instruction; the loop appends it only after the
|
||||
* `tool/result`.
|
||||
*/
|
||||
export interface ToolRunContext extends ToolExecution {
|
||||
/**
|
||||
* Defer one nested-dispatch context until this tool's final result reaches
|
||||
* the agent loop. Contexts retain their individual source and metadata and
|
||||
* are emitted in call order.
|
||||
* Defer one context — typically a nested-dispatch context ferried by a
|
||||
* composite tool, or a fresh plugin-sourced instruction — until this tool's
|
||||
* final result reaches the agent loop. Contexts retain their individual
|
||||
* source and metadata and are emitted in call order.
|
||||
*/
|
||||
deferContext(context: UserMessage): void
|
||||
/**
|
||||
|
||||
@@ -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/goal/tool-goal/README.md
|
||||
README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46
|
||||
README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8
|
||||
README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80
|
||||
README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147
|
||||
|
||||
@@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and
|
||||
|
||||
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
|
||||
|
||||
An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
|
||||
An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
|
||||
|
||||
## Authority
|
||||
|
||||
@@ -61,11 +61,11 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority.
|
||||
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one `<goal_complete>`/`<goal_blocked>` wrap-up instruction that asks for a grounded closing message to the user without further tool calls.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction.
|
||||
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }` 或 `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON,即可收到相同领域结构。
|
||||
|
||||
自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会用 `concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更绝不会导致这种停止:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。
|
||||
自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。
|
||||
|
||||
## 权限
|
||||
|
||||
@@ -61,11 +61,11 @@ Use goal tools for one long-running completion objective in the current session.
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
|
||||
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 `<goal_complete>`/`<goal_blocked>` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。
|
||||
固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
goalToolExecution,
|
||||
requireDirectHuman,
|
||||
} from './authority.ts'
|
||||
import { renderWrapupContext } from './wrapup.ts'
|
||||
|
||||
export const name = 'tool-goal'
|
||||
export const inject = ['agents', 'goals', 'tools', 'systemPrompt']
|
||||
@@ -309,7 +310,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
code: 'model-reported',
|
||||
message: args.blocked_reason as string,
|
||||
})
|
||||
if (authority.kind === 'goal-round') exec.concludeTurn()
|
||||
if (authority.kind === 'goal-round') {
|
||||
exec.deferContext(createUserMessage({
|
||||
content: args.action === 'complete'
|
||||
? renderWrapupContext(goal.objective)
|
||||
: renderWrapupContext(goal.objective, args.blocked_reason as string),
|
||||
source: { kind: 'plugin', plugin: 'tool-goal' },
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(goalValue(goal))
|
||||
},
|
||||
presentCall: args => present(
|
||||
|
||||
41
packages/goal/tool-goal/src/wrapup.ts
Normal file
41
packages/goal/tool-goal/src/wrapup.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** Model-visible wrap-up instruction for a terminal autonomous goal update. */
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
const GROUNDING =
|
||||
'Report only what earlier rounds and tool results in this session actually establish; '
|
||||
+ 'when a detail is not in the session, say so instead of inventing it. '
|
||||
|
||||
/**
|
||||
* Render the closing-message instruction injected after an autonomous goal
|
||||
* round reports `complete` or `blocked`, replacing the former hard turn stop
|
||||
* so the model still addresses the user once before the turn ends.
|
||||
* @param objective - the terminal goal's objective, echoed for grounding.
|
||||
* @param blockedReason - the validated report for `blocked`; omitted for `complete`.
|
||||
* @returns a fresh one-block context for `ToolRunContext.deferContext()`.
|
||||
*/
|
||||
export function renderWrapupContext(objective: string, blockedReason?: string): ContentBlock[] {
|
||||
const heading = `Objective: ${JSON.stringify(objective)}\n`
|
||||
const text = blockedReason === undefined
|
||||
? '<goal_complete>\n'
|
||||
+ heading
|
||||
+ 'The goal is marked complete and this autonomous run is ending. Write the closing '
|
||||
+ 'message to the user now: state the outcome, summarize what was done and how it was '
|
||||
+ 'verified, and point to the concrete results (files, commits, or other artifacts). '
|
||||
+ GROUNDING
|
||||
+ 'Note anything the user should review or do next. Address the user directly. Do not '
|
||||
+ "call any more tools in this run; further work waits for the user's next instruction.\n"
|
||||
+ '</goal_complete>'
|
||||
: '<goal_blocked>\n'
|
||||
+ heading
|
||||
+ `Blocked: ${JSON.stringify(blockedReason)}\n`
|
||||
+ 'The goal is marked blocked and this autonomous run is ending. Write the closing '
|
||||
+ 'message to the user now: state what has been completed so far, describe the concrete '
|
||||
+ 'blocking condition and what you tried, and say exactly what you need from the user to '
|
||||
+ 'continue. '
|
||||
+ GROUNDING
|
||||
+ 'Address the user directly. Do not call any more tools in this run; further work '
|
||||
+ "waits for the user's next instruction.\n"
|
||||
+ '</goal_blocked>'
|
||||
return [{ type: 'text', text }]
|
||||
}
|
||||
@@ -347,7 +347,7 @@ describe('goal tool state transitions', () => {
|
||||
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
|
||||
})
|
||||
|
||||
it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
|
||||
it('injects one wrap-up instruction for an autonomous completion but leaves a human pause interactive', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' })
|
||||
@@ -356,6 +356,7 @@ describe('goal tool state transitions', () => {
|
||||
}, root.agent)
|
||||
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
|
||||
expect(paused.concludesTurn).toBeUndefined()
|
||||
expect(paused.additionalContexts).toBeUndefined()
|
||||
const resumed = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: 2, action: 'resume',
|
||||
}, root.agent))
|
||||
@@ -368,7 +369,27 @@ describe('goal tool state transitions', () => {
|
||||
goal_id: created.id, revision: resumed['revision'], action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
|
||||
expect(complete.concludesTurn).toBe(true)
|
||||
expect(complete.concludesTurn).toBeUndefined()
|
||||
const contexts = complete.additionalContexts ?? []
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' })
|
||||
const block = contexts[0]?.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
|
||||
expect(block.text).toContain('<goal_complete>')
|
||||
expect(block.text).toContain('"pause cleanly"')
|
||||
expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.")
|
||||
})
|
||||
|
||||
it('completes without a wrap-up instruction under direct human authority', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'finish now' })
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
|
||||
expect(complete.concludesTurn).toBeUndefined()
|
||||
expect(complete.additionalContexts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rearms a restored active goal only after a new direct human prompt', async () => {
|
||||
@@ -550,6 +571,14 @@ describe('goal tool state transitions', () => {
|
||||
blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' },
|
||||
roundsStarted: 3,
|
||||
})
|
||||
expect(blocked.concludesTurn).toBeUndefined()
|
||||
const contexts = blocked.additionalContexts ?? []
|
||||
expect(contexts).toHaveLength(1)
|
||||
const block = contexts[0]?.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
|
||||
expect(block.text).toContain('<goal_blocked>')
|
||||
expect(block.text).toContain('The required credential is still unavailable.')
|
||||
expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.")
|
||||
})
|
||||
|
||||
it('lets direct human authority block before the model threshold', async () => {
|
||||
@@ -570,5 +599,7 @@ describe('goal tool state transitions', () => {
|
||||
},
|
||||
roundsStarted: 0,
|
||||
})
|
||||
expect(blocked.concludesTurn).toBeUndefined()
|
||||
expect(blocked.additionalContexts).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 933b5f6167263545b3bef5ca9fb8f8b945e86ef9
|
||||
README.zh.md: f1f1106dbd0c50889eca6f6ae52fbb29d1c4c03f
|
||||
README.md: d4af267ef46f327bdabf31429b291ffd04308203
|
||||
README.zh.md: f148cfa0c023b54d62016c5211205d391b634544
|
||||
|
||||
@@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the
|
||||
|
||||
Generic Agent-bound session, command, and goal operations serve ordinary sessions only. They return `agent-busy` for a session-backed subagent instead of resuming or driving it; explicit-id `session.create` adoption and the attached-only queue controls enforce the same ownership boundary. Subagent conversation reads and continuation use the dedicated `subagent.*` domain, which retains catalog-mode and direct-parent authorization.
|
||||
|
||||
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `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 occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
|
||||
Pending inbox input is a live control-plane contract, not session history. The gateway mirrors `InboxItem` occurrences from `agent/inbox/*` with their `queued` or `steering` placement and broadcasts authoritative `session/queue` snapshots on every change and reconnect. A steering occurrence remains in this projection until the corresponding durable `steering/message` has been published, preserving the Host's linear event order during the handoff. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `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 occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached ordinary-session Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. 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()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent,它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
待处理的 inbox 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 `InboxItem` 入队项及其 `queued` 或 `steering` placement,并在每次变更和重连时广播权威的 `session/queue` 快照。steering 入队项会一直保留在该投影中,直到对应的持久 `steering/message` 已发布,从而在交接期间保持 Host 的线性事件顺序。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
|
||||
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()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。
|
||||
|
||||
|
||||
@@ -776,10 +776,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
|
||||
/**
|
||||
* Per-session queued-occurrence mirror serving the mux-open queue snapshot
|
||||
* (the same refresh-recovery baseline as pending questions). Each terminal
|
||||
* queue event retires one matching occurrence, so repeated sends of the same
|
||||
* identified message remain visible until every occurrence is claimed.
|
||||
* Per-session pending-occurrence mirror serving live and mux-open
|
||||
* `session/queue` snapshots. It carries both queued and steering placements.
|
||||
* Each terminal inbox event retires one matching occurrence, so repeated
|
||||
* sends of the same identified message remain visible until every occurrence
|
||||
* is claimed.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, InboxItem[]>()
|
||||
type UnseenQueueEvent =
|
||||
@@ -818,29 +819,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
sessionId,
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
placement: item.placement,
|
||||
message: item.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const retire = (agent: Agent, item: InboxItem): boolean => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
|
||||
return false
|
||||
}
|
||||
const index = entries.findIndex(entry => entry.id === item.id)
|
||||
if (index === -1) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
|
||||
return false
|
||||
}
|
||||
const retireKnown = (sessionId: SessionId, itemId: InboxItemId): boolean => {
|
||||
const entries = queuedMirror.get(sessionId)
|
||||
if (entries === undefined) return false
|
||||
const index = entries.findIndex(entry => entry.id === itemId)
|
||||
if (index === -1) return false
|
||||
entries.splice(index, 1)
|
||||
if (entries.length === 0) queuedMirror.delete(agent.id)
|
||||
if (entries.length === 0) queuedMirror.delete(sessionId)
|
||||
return true
|
||||
}
|
||||
const retire = (agent: Agent, item: InboxItem): boolean => {
|
||||
if (retireKnown(agent.id, item.id)) return true
|
||||
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
|
||||
return false
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
|
||||
if (item.placement !== 'queued') return
|
||||
const unseen = takeUnseen(agent.id, item.id)
|
||||
if (unseen?.kind === 'terminal') return
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
@@ -866,7 +866,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
|
||||
if (retire(agent, item)) publishQueue(agent.id)
|
||||
if (item.placement === 'steering') {
|
||||
// AgentLoop appends the durable steering/message synchronously after
|
||||
// this claim. Retain and retire the mirror row in the following
|
||||
// microtask so any re-entrant snapshot and the Host's linear mux
|
||||
// stream keep it visible until the durable event exists.
|
||||
const present = queuedMirror.get(agent.id)?.some(entry => entry.id === item.id) === true
|
||||
if (!present) {
|
||||
retire(agent, item)
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (retireKnown(agent.id, item.id)) publishQueue(agent.id)
|
||||
})
|
||||
} else if (retire(agent, item)) {
|
||||
// Queued claims have no durable same-message handoff to order.
|
||||
// Publish retirement synchronously as before.
|
||||
publishQueue(agent.id)
|
||||
}
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => {
|
||||
let changed = false
|
||||
@@ -1882,13 +1899,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
|
||||
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
|
||||
}
|
||||
if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
|
||||
if (agent === undefined) {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'queue-item-not-found',
|
||||
message: 'queued item is no longer pending',
|
||||
details: { itemId },
|
||||
}))
|
||||
}
|
||||
const result = agent.updateInbox(itemId, action)
|
||||
if (result === 'not-found') {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'queue-item-not-found',
|
||||
message: 'queued item is no longer pending',
|
||||
details: { itemId },
|
||||
}))
|
||||
}
|
||||
if (result === 'steer-unavailable') {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'steer-unavailable',
|
||||
message: 'current turn no longer accepts steering',
|
||||
details: { itemId },
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok(request, { accepted: true as const }))
|
||||
},
|
||||
|
||||
@@ -2531,6 +2563,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
sessionId,
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
placement: item.placement,
|
||||
message: item.message,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -54,6 +54,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
sessionId: sessionIdSchema,
|
||||
items: z.array(z.object({
|
||||
id: inboxItemIdSchema,
|
||||
placement: z.union([z.literal('queued'), z.literal('steering')]),
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -32,10 +32,12 @@ export type ToolEventView =
|
||||
| { for: 'call'; view: ToolCallView }
|
||||
| { for: 'result'; view: ToolResultView }
|
||||
|
||||
/** One pending queued occurrence in an authoritative queue snapshot. */
|
||||
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedInboxItem {
|
||||
/** Agent-owned occurrence identity used by queue mutations. */
|
||||
/** Agent-owned occurrence identity; queue mutations address only `queued` items. */
|
||||
id: InboxItemId
|
||||
/** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */
|
||||
placement: 'queued' | 'steering'
|
||||
/** Complete pending message; it is not durable until the Agent claims it. */
|
||||
message: Message
|
||||
}
|
||||
@@ -71,11 +73,12 @@ export type MuxFrame =
|
||||
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
|
||||
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
|
||||
/**
|
||||
* Complete transient queue state after every enqueue, mutation, claim, or
|
||||
* Complete transient inbox state after every enqueue, mutation, claim, or
|
||||
* discard. Pending work is not model-visible and therefore has no durable
|
||||
* session event; the whole snapshot makes edit, deletion, cancel, and
|
||||
* reconnect converge through one authoritative signal. Pending steering is
|
||||
* outside this Web queue projection.
|
||||
* reconnect converge through one authoritative signal. `session/queue`
|
||||
* covers both resolved placements: queued items render
|
||||
* in QueueDock, while pending steering renders at the conversation tail.
|
||||
*/
|
||||
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-busy': { reason: string }
|
||||
'queue-item-not-found': { itemId: InboxItemId }
|
||||
'steer-unavailable': { itemId: InboxItemId }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
|
||||
@@ -269,6 +269,7 @@ export const sessionUpdateQueueRequestSchema = z.object({
|
||||
action: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
|
||||
z.object({ kind: z.literal('remove') }),
|
||||
z.object({ kind: z.literal('steer') }),
|
||||
]),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface SessionModels {
|
||||
export type QueueAction =
|
||||
| { kind: 'edit'; content: ContentBlock[] }
|
||||
| { kind: 'remove' }
|
||||
| { kind: 'steer' }
|
||||
|
||||
/** Session list entry (v1 builds no index: list does readdir+stat). */
|
||||
export interface SessionSummary {
|
||||
@@ -285,7 +286,7 @@ export interface SessionsApi {
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
/**
|
||||
* Edits or removes one pending queued occurrence on an ordinary session.
|
||||
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
|
||||
* Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
|
||||
|
||||
@@ -291,13 +291,14 @@ function inboxItem(id: string, message: UserMessage, placement: InboxPlacement):
|
||||
}
|
||||
|
||||
describe('session.updateQueue', () => {
|
||||
it('routes an addressable action and reports a lost claim race', async () => {
|
||||
it('routes addressable actions and reports strict steer races', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const seen: unknown[] = []
|
||||
agent.updateInbox = (id, action) => {
|
||||
seen.push({ id, action })
|
||||
return id === InboxItemId('present') ? 'applied' : 'not-found'
|
||||
if (id === InboxItemId('present')) return 'applied'
|
||||
return id === InboxItemId('closed') ? 'steer-unavailable' : 'not-found'
|
||||
}
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
@@ -319,9 +320,22 @@ describe('session.updateQueue', () => {
|
||||
},
|
||||
})
|
||||
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
|
||||
const closed = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-closed'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('closed'),
|
||||
action: { kind: 'steer' },
|
||||
},
|
||||
})
|
||||
expect(expectErr(closed)).toMatchObject({
|
||||
code: 'steer-unavailable',
|
||||
details: { itemId: 'closed' },
|
||||
})
|
||||
expect(seen).toEqual([
|
||||
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
|
||||
{ id: 'claimed', action: { kind: 'remove' } },
|
||||
{ id: 'closed', action: { kind: 'steer' } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -365,7 +379,7 @@ describe('session/queue frames', () => {
|
||||
|
||||
const liveFrames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(liveFrames.map(frame => frame.items)).toEqual([
|
||||
[{ id: edited.id, message: edited.message }],
|
||||
[{ id: edited.id, placement: edited.placement, message: edited.message }],
|
||||
])
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
@@ -373,14 +387,41 @@ describe('session/queue frames', () => {
|
||||
expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames)
|
||||
})
|
||||
|
||||
it('expires unmatched mutations after the synchronous re-entry window', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const original = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'original'), 'queued')
|
||||
const staleEdit = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'stale edit'), 'queued')
|
||||
const staleTerminal = inboxItem('i-stale-terminal', inboxMessage('m-stale-terminal', 'keep me'), 'queued')
|
||||
|
||||
ctx.emit('agent/inbox/update', agent, staleEdit)
|
||||
ctx.emit('agent/inbox/discard', agent, [staleTerminal])
|
||||
await Promise.resolve()
|
||||
ctx.emit('agent/inbox/enqueue', agent, original)
|
||||
ctx.emit('agent/inbox/enqueue', agent, staleTerminal)
|
||||
|
||||
const replay = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-expired-unseen'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [
|
||||
{ id: original.id, placement: original.placement, message: original.message },
|
||||
{ id: staleTerminal.id, placement: staleTerminal.placement, message: staleTerminal.message },
|
||||
],
|
||||
}])
|
||||
})
|
||||
|
||||
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const live = new AbortController()
|
||||
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
|
||||
// subscribed baseline + one queued snapshot; pending steering stays off this wire.
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 2, live)
|
||||
// subscribed baseline + one snapshot per accepted inbox occurrence.
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
|
||||
|
||||
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
|
||||
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
|
||||
@@ -392,7 +433,15 @@ describe('session/queue frames', () => {
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [{ id: queued.id, message: queued.message }],
|
||||
items: [{ id: queued.id, placement: 'queued', message: queued.message }],
|
||||
},
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [
|
||||
{ id: queued.id, placement: 'queued', message: queued.message },
|
||||
{ id: steering.id, placement: 'steering', message: steering.message },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -400,7 +449,88 @@ describe('session/queue frames', () => {
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]])
|
||||
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]])
|
||||
})
|
||||
|
||||
it('publishes the durable steering event before retiring its transient row', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const abort = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-steering-order'), payload: {} }, abort.signal), 5, abort)
|
||||
const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering')
|
||||
|
||||
agent.session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering)
|
||||
agent.session.append('steering/message', {
|
||||
turn: 1,
|
||||
message: steering.message,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.map(frame => frame.type)).toEqual([
|
||||
'session/subscribed',
|
||||
'session/event',
|
||||
'session/queue',
|
||||
'session/event',
|
||||
'session/queue',
|
||||
])
|
||||
expect(frames[2]).toMatchObject({
|
||||
type: 'session/queue',
|
||||
items: [{ id: steering.id, placement: 'steering' }],
|
||||
})
|
||||
expect(frames[3]).toMatchObject({
|
||||
type: 'session/event',
|
||||
event: { type: 'steering/message', data: { message: { id: steering.message.id } } },
|
||||
})
|
||||
expect(frames[4]).toMatchObject({ type: 'session/queue', items: [] })
|
||||
})
|
||||
|
||||
it('retains claimed steering in re-entrant snapshots until its durable event', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering')
|
||||
const queued = inboxItem('i-reentrant', inboxMessage('m-reentrant', 'later'), 'queued')
|
||||
ctx.on('agent/inbox/dequeue', (subject, item) => {
|
||||
if (subject === agent && item.id === steering.id) ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-steering-reentrant-order'), payload: {} }, abort.signal), 6, abort)
|
||||
|
||||
agent.session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering)
|
||||
agent.session.append('steering/message', {
|
||||
turn: 1,
|
||||
message: steering.message,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames[3]).toMatchObject({
|
||||
type: 'session/queue',
|
||||
items: [
|
||||
{ id: steering.id, placement: 'steering' },
|
||||
{ id: queued.id, placement: 'queued' },
|
||||
],
|
||||
})
|
||||
expect(frames[4]).toMatchObject({
|
||||
type: 'session/event',
|
||||
event: { type: 'steering/message', data: { message: { id: steering.message.id } } },
|
||||
})
|
||||
expect(frames[5]).toMatchObject({
|
||||
type: 'session/queue',
|
||||
items: [{ id: queued.id, placement: 'queued' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes edits in place in the authoritative order', async () => {
|
||||
@@ -420,10 +550,16 @@ describe('session/queue frames', () => {
|
||||
|
||||
const frames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(frames.map(frame => frame.items)).toEqual([
|
||||
[{ id: first.id, message: first.message }],
|
||||
[{ id: first.id, message: first.message }, { id: second.id, message: second.message }],
|
||||
[{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }],
|
||||
[{ id: first.id, message: first.message }],
|
||||
[{ id: first.id, placement: first.placement, message: first.message }],
|
||||
[
|
||||
{ id: first.id, placement: first.placement, message: first.message },
|
||||
{ id: second.id, placement: second.placement, message: second.message },
|
||||
],
|
||||
[
|
||||
{ id: first.id, placement: first.placement, message: first.message },
|
||||
{ id: edited.id, placement: edited.placement, message: edited.message },
|
||||
],
|
||||
[{ id: first.id, placement: first.placement, message: first.message }],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ describe('rpcErrorSchema', () => {
|
||||
}).code).toBe('model-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||
expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'steer-unavailable', message: 'm', details: { itemId: 'i' } }).code).toBe('steer-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
|
||||
@@ -280,6 +281,9 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
|
||||
}).action.kind).toBe('remove')
|
||||
expect(sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'steer' },
|
||||
}).action.kind).toBe('steer')
|
||||
expect(() => sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'promote' },
|
||||
})).toThrow()
|
||||
@@ -477,7 +481,7 @@ describe('events frame schemas', () => {
|
||||
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
||||
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
||||
{ type: 'session/queue', sessionId: 's', items: [
|
||||
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
|
||||
{ id: 'i1', placement: 'steering', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
|
||||
@@ -140,7 +140,7 @@ async function inspectChild(
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
// The length-one branch proves this exact-read sequence exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const seq = descriptorSeqs[0]!
|
||||
const window = await runListingQuery(
|
||||
() => query.readEvent({ sessionId: childId, seq }, signal),
|
||||
|
||||
@@ -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/support/llm-replay/README.md
|
||||
README.md: 0deb6e76b29d40483b754ac01c98ee0e01bfcbe8
|
||||
README.zh.md: 7720e2d1bc6eb7bc5c89d5c1708767a54a7b0080
|
||||
README.md: 85aa56705929e7630e4cfb6c2a3c9cbbd0d843a6
|
||||
README.zh.md: 751f75dea197ffb112cfa703e3a5dbfaffb8c0b2
|
||||
|
||||
@@ -12,6 +12,8 @@ The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assi
|
||||
|
||||
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update.
|
||||
|
||||
A scripted string may embed `{{fromRequest:<regex>}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud. The last two braces of a consecutive `}` run terminate the placeholder, so a pattern may end with a brace quantifier (`[0-9a-f]{4}`) but cannot contain `}}` followed by further pattern content. Resolution applies to every scripted entry, including ones derived from the recorded JSONL — a recorded fixture whose text legitimately contains the literal marker must be expressed through a sidecar without it.
|
||||
|
||||
## Nested agents: per-session keying
|
||||
|
||||
A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script.
|
||||
@@ -55,7 +57,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
|
||||
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars.
|
||||
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
|
||||
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing).
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`.
|
||||
|
||||
## Plugin export shape
|
||||
|
||||
@@ -12,6 +12,8 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
|
||||
|
||||
有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`<scenario>/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。
|
||||
|
||||
脚本字符串可以内嵌 `{{fromRequest:<regex>}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错。连续右花括号串的最后两个花括号才是占位符结束符,因此模式可以以花括号量词收尾(如 `[0-9a-f]{4}`),但不能在 `}}` 之后还有后续模式内容。解析作用于所有脚本条目,包括从已记录 JSONL 派生的条目——若录制文本本身合法地含有该字面量标记,需改用不含标记的伴随文件表达。
|
||||
|
||||
## 嵌套 agent:每会话键控
|
||||
|
||||
父 agent 委托给进程内 subagent(子 agent)的场景会记录多个日志:父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。
|
||||
@@ -55,7 +57,7 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
|
||||
- `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。
|
||||
- `loadSessionScripts(config)`:解析场景的有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。
|
||||
- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本并读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。
|
||||
- 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。
|
||||
|
||||
## 插件导出形态
|
||||
|
||||
@@ -241,6 +241,96 @@ const REPLAY_CHUNK_TYPES = new Set<StreamChunk['type']>([
|
||||
'finish',
|
||||
])
|
||||
|
||||
const FROM_REQUEST_OPEN = '{{fromRequest:'
|
||||
const FROM_REQUEST_CLOSE = '}}'
|
||||
|
||||
/** Collect every string leaf of one JSON-shaped value, in traversal order. */
|
||||
function collectStrings(value: unknown, out: string[]): void {
|
||||
if (typeof value === 'string') {
|
||||
out.push(value)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectStrings(item, out)
|
||||
return
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
for (const item of Object.values(value)) collectStrings(item, out)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one placeholder pattern against the request corpus; the LAST match wins. */
|
||||
function resolveFromRequest(pattern: string, corpus: string): string {
|
||||
let regex: RegExp
|
||||
try {
|
||||
regex = new RegExp(pattern, 'g')
|
||||
} catch (error) {
|
||||
// RegExp construction only throws SyntaxError; String() carries its message.
|
||||
throw new Error(`llm-replay: fromRequest has an invalid pattern ${JSON.stringify(pattern)}: ${String(error)}`)
|
||||
}
|
||||
let last: RegExpExecArray | undefined
|
||||
for (const match of corpus.matchAll(regex)) last = match
|
||||
if (last === undefined) {
|
||||
throw new Error(`llm-replay: fromRequest pattern ${JSON.stringify(pattern)} matched nothing in the request`)
|
||||
}
|
||||
return last[1] ?? last[0]
|
||||
}
|
||||
|
||||
/** Replace every `{{fromRequest:<pattern>}}` occurrence in one scripted string. */
|
||||
function substituteString(text: string, corpus: string): string {
|
||||
let result = ''
|
||||
let cursor = 0
|
||||
while (true) {
|
||||
const open = text.indexOf(FROM_REQUEST_OPEN, cursor)
|
||||
if (open === -1) return result + text.slice(cursor)
|
||||
let close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length)
|
||||
if (close === -1) {
|
||||
throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`)
|
||||
}
|
||||
// The last two braces of a consecutive `}` run terminate the placeholder,
|
||||
// so a pattern may end with a brace quantifier like `[0-9a-f]{4}`.
|
||||
while (text[close + FROM_REQUEST_CLOSE.length] === '}') close += 1
|
||||
const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close)
|
||||
result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus)
|
||||
cursor = close + FROM_REQUEST_CLOSE.length
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */
|
||||
function substituteValue(value: unknown, corpus: string): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(item => substituteValue(item, corpus))
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, corpus)]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every `{{fromRequest:<regex>}}` placeholder in one scripted entry
|
||||
* against the live request. The corpus is every string leaf of the request
|
||||
* messages joined by newlines; the pattern's LAST corpus match wins and its
|
||||
* first capture group (or, without one, the whole match) substitutes in place.
|
||||
* Scenario sidecars use this to script arguments no static file can know,
|
||||
* such as a randomly minted goal id the model must echo back. A pattern that
|
||||
* matches nothing, an invalid pattern, and an unterminated placeholder each
|
||||
* fail loud. The last two braces of a consecutive `}` run terminate the
|
||||
* placeholder, so a pattern may end with a brace quantifier but cannot
|
||||
* contain `}}` followed by further pattern content. Derived entries pass
|
||||
* through the same resolution as sidecar entries.
|
||||
* @param entry - the scripted entry about to replay.
|
||||
* @param messages - the live request messages searched by the placeholders.
|
||||
* @returns the entry itself when no placeholder appears, else a resolved deep copy.
|
||||
*/
|
||||
export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry {
|
||||
if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry
|
||||
const leaves: string[] = []
|
||||
collectStrings(messages, leaves)
|
||||
return substituteValue(entry, leaves.join('\n')) as ReplayEntry
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -583,7 +673,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand
|
||||
+ `but its script has only ${boundState.entries.length}; re-record the scenario`,
|
||||
)
|
||||
}
|
||||
yield* replayEntry(entry, options.signal, paceMs)
|
||||
yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs)
|
||||
})()
|
||||
}
|
||||
const providers = config.providers ?? []
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
type ReplayEntry,
|
||||
type SessionScript,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
name,
|
||||
parseSessionHeader,
|
||||
parseSessionLog,
|
||||
resolveScriptedEntry,
|
||||
} from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -310,6 +311,80 @@ describe('installLlmReplay (through the real LlmService)', () => {
|
||||
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
|
||||
})
|
||||
|
||||
describe('{{fromRequest:...}} substitution', () => {
|
||||
const requestMessages = [createUserMessage({
|
||||
content: [{ type: 'text' as const, text: 'stale {"goal":{"id":"goal-old"}} then {"goal":{"id":"goal-42ab"}}' }],
|
||||
source: { kind: 'user' as const },
|
||||
})]
|
||||
|
||||
function scriptedCall(argumentsDelta: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'update_goal', argumentsDelta },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'update_goal', arguments: argumentsDelta } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
async function streamScripted(argumentsDelta: string): Promise<StreamChunk[]> {
|
||||
writeLog(TEXT_CHUNKS)
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
writeFileSync(overrideFile, JSON.stringify([{ kind: 'chunks', chunks: scriptedCall(argumentsDelta) }]), 'utf8')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file, overrideFile })
|
||||
return drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: requestMessages }))
|
||||
}
|
||||
|
||||
it('resolves the capture group from the LAST request match in every scripted string field', async () => {
|
||||
const streamed = await streamScripted('{"goal_id":"{{fromRequest:"id":"(goal-[^"]+)"}}","revision":1}')
|
||||
const delta = streamed.find(chunk => chunk.type === 'tool-call-delta')
|
||||
expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab","revision":1}' })
|
||||
const end = streamed.find(chunk => chunk.type === 'block-end')
|
||||
expect(end).toMatchObject({ block: { arguments: '{"goal_id":"goal-42ab","revision":1}' } })
|
||||
})
|
||||
|
||||
it('substitutes the whole match when the pattern has no capture group', async () => {
|
||||
const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]+}}"}')
|
||||
const delta = streamed.find(chunk => chunk.type === 'tool-call-delta')
|
||||
expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' })
|
||||
})
|
||||
|
||||
it('keeps a trailing brace quantifier inside the pattern (terminator is the run tail)', async () => {
|
||||
const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]{4}}}"}')
|
||||
const delta = streamed.find(chunk => chunk.type === 'tool-call-delta')
|
||||
expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' })
|
||||
})
|
||||
|
||||
it('fails loud when a placeholder matches nothing in the request', async () => {
|
||||
await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}'))
|
||||
.rejects.toThrow(/fromRequest.*matched nothing/)
|
||||
})
|
||||
|
||||
it('fails loud on an invalid placeholder pattern', async () => {
|
||||
await expect(streamScripted('{"goal_id":"{{fromRequest:(goal-}}"}'))
|
||||
.rejects.toThrow(/fromRequest.*invalid pattern/)
|
||||
})
|
||||
|
||||
it('fails loud on an unterminated placeholder', () => {
|
||||
const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-1"}') }
|
||||
expect(() => resolveScriptedEntry(entry, requestMessages)).toThrow(/fromRequest placeholder is unterminated/)
|
||||
})
|
||||
|
||||
it('returns the exact same entry when no placeholder appears', () => {
|
||||
const entry: ReplayEntry = { kind: 'chunks', chunks: TEXT_CHUNKS }
|
||||
expect(resolveScriptedEntry(entry, requestMessages)).toBe(entry)
|
||||
})
|
||||
|
||||
it('skips non-string request leaves when building the corpus', () => {
|
||||
const messages = requestMessages.map(message => ({ ...message, seq: 7 })) as unknown as GenerateOptions['messages']
|
||||
const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-42[a-z]+}}"}') }
|
||||
const resolved = resolveScriptedEntry(entry, messages)
|
||||
if (resolved.kind !== 'chunks') throw new Error('expected chunks entry')
|
||||
expect(resolved.chunks[1]).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' })
|
||||
})
|
||||
})
|
||||
|
||||
it('registers a replay-only provider catalog when configured', async () => {
|
||||
writeLog(TEXT_CHUNKS)
|
||||
const ctx = new Context()
|
||||
|
||||
Reference in New Issue
Block a user