diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index adbc01c..67c9313 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -125,7 +125,7 @@ export function registerChatCommand(program: Command, t: Messages): void { // regular input. if (session.resumedHistory) { out.write(`${t.resumedBanner(session.sessionId, session.resumedHistory.length)}\n`); - renderHistory(session.resumedHistory, out); + renderHistory(session.resumedHistory, out, t); } // TTY: raw mode + bracketed paste + PasteFilter; non-TTY (pipe/test): read stdin directly. @@ -148,11 +148,26 @@ export function registerChatCommand(program: Command, t: Messages): void { const rli = rl as unknown as RlInternals; const composer = new LineComposer(); + // Typing lock (owner directive): while the user is composing a line mid-run, streamed + // output must not scribble over it — the renderer holds (queues) output while the input + // buffer is non-empty and flushes once the line is submitted or cleared. Synced after + // readline has processed each chunk (setImmediate: listener order is not guaranteed); + // TTY only — non-TTY input (pipe/tests) has no echoed line to protect. The approval + // prompt has its own lock (beginUserPrompt), so the hold applies to "running" only. + const syncInputHold = (): void => { + renderer.setInputHold(state === "running" && rli.line.length > 0); + }; + if (isTTY) { + inputStream.on("data", () => setImmediate(syncInputHold)); + } + let state: ChatState = "idle"; let closed = false; let taskAbort: AbortController | null = null; let pendingLine: ((line: string | null) => void) | null = null; let pendingApproval: ((decision: ApprovalDecision) => void) | null = null; + /** A line typed in the running->idle race window (steer refused): becomes the next prompt instead of being dropped. */ + let queuedPrompt: string | null = null; const cleanup = () => { if (!isTTY) return; @@ -221,8 +236,24 @@ export function registerChatCommand(program: Command, t: Messages): void { pendingApproval = null; // Tool approval defaults to allow: pressing Enter (empty input) is treated as allow. resolve(parseApprovalAnswer(line, "allow")); + return; + } + // running: a non-empty line becomes a steering message for the running Task — core + // delivers it between turns as a standalone [user_steering] user message, so the + // model sees it without the loop being interrupted. Empty lines are still ignored. + // steer() returning false (race: the task just finished) must not drop the line: + // it is stashed and becomes the next normal prompt as soon as the loop asks again. + if (state === "running") { + const text = line.trim(); + if (text.length === 0) return; + if (session.steer(text)) { + // Printed via the renderer while the typing hold is still engaged, so the ack + // lands before the held stream output flushes underneath it. + renderer.printLine(dim(t.steerQueued(text))); + } else { + queuedPrompt = text; + } } - // running: ignore any line typed at this moment. }); rl.on("SIGINT", () => { @@ -272,6 +303,15 @@ export function registerChatCommand(program: Command, t: Messages): void { resolve(null); return; } + // A line typed in the running->idle race window (steer refused): submit it as this + // prompt immediately — the text is already echoed on screen, no re-prompt needed. + if (queuedPrompt !== null) { + const line = queuedPrompt; + queuedPrompt = null; + state = "idle"; + resolve(line); + return; + } state = "idle"; pendingLine = resolve; composer.reset(); diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 4187be4..0fe7c4e 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -125,6 +125,10 @@ export interface Messages { chatHints(): string; confirmExit(): string; taskInterrupted(): string; + /** Acknowledgment printed when a line typed mid-run is queued as steering (echoes the text; delivered between turns). */ + steerQueued(text: string): string; + /** Prefix for a rendered [user_steering] line (a mid-run user message delivered between turns). */ + steerLinePrefix(): string; error(message: string): string; /** Approval prompt text (the tool call is already streamed above and directly precedes this prompt, so no index and no re-rendering). */ approvePrompt(): string; @@ -343,9 +347,11 @@ const en: Messages = { header: headerEn, chatHints: () => - "Type a message to start a conversation; end a line with \\; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.", + "Type a message to start a conversation; end a line with \\; typing while a task runs steers the agent; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.", confirmExit: () => "Exit penguin? [y/N] ", taskInterrupted: () => "[current conversation interrupted]", + steerQueued: (text) => `» steering queued (delivered with the next turn): ${text}`, + steerLinePrefix: () => "↪ user: ", error: (message) => `[error] ${message}`, approvePrompt: () => "? Approve this tool call? [Y/n] ", taskStats: (s) => @@ -508,9 +514,11 @@ const zh: Messages = { header: headerZh, chatHints: () => - "输入消息发起对话;行尾 \\ 续行;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。", + "输入消息发起对话;行尾 \\ 续行;运行中输入可插话引导;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。", confirmExit: () => "确认退出 penguin?[y/N] ", taskInterrupted: () => "[已中断当前对话]", + steerQueued: (text) => `» 插话已排队(随下一轮送达):${text}`, + steerLinePrefix: () => "↪ 用户: ", error: (message) => `[错误] ${message}`, approvePrompt: () => "? 批准此工具调用?[Y/n] ", taskStats: (s) => diff --git a/packages/cli/src/render.ts b/packages/cli/src/render.ts index 3996676..66715c2 100644 --- a/packages/cli/src/render.ts +++ b/packages/cli/src/render.ts @@ -40,7 +40,7 @@ * * No third-party color library is used; only minimal ANSI escapes. */ -import { isEventMessage, isModelMessage } from "@prismshadow/penguin-core"; +import { isEventMessage, isModelMessage, parseUserSteeringText } from "@prismshadow/penguin-core"; import type { AbortPayload, ApprovalDecision, @@ -55,6 +55,7 @@ import type { PartialToolCallOutputPayload, RequestEndPayload, SessionMetaPayload, + TextPayload, TokenUsagePayload, ToolCallPayload, ToolDefinition, @@ -67,6 +68,7 @@ const DIM = "\x1b[2m"; const GREEN = "\x1b[32m"; const RED = "\x1b[31m"; const CYAN = "\x1b[36m"; +const MAGENTA = "\x1b[35m"; const RESET = "\x1b[0m"; export function dim(text: string): string { @@ -187,8 +189,18 @@ export function renderHistory( const marker = p.stop_reason && p.stop_reason !== "completed" ? dim(` [${p.stop_reason}]`) : ""; switch (p.type) { case "text": - if (p.role === "user") out.write(`\n> ${p.text ?? ""}\n`); - else out.write(`${p.text ?? ""}${marker}\n`); + if (p.role === "user") { + // Mid-run steering ([user_steering]-wrapped user text delivered between turns): + // rendered as distinct user-speech lines instead of a raw marker block or a prompt. + const steering = parseUserSteeringText(p.text ?? ""); + if (steering !== null) { + writeSteeringLines(out, steering, t); + } else { + out.write(`\n> ${p.text ?? ""}\n`); + } + } else { + out.write(`${p.text ?? ""}${marker}\n`); + } break; case "image_url": out.write(`\n> ${dim("[image]")}\n`); @@ -231,6 +243,13 @@ export function renderHistory( } } +/** Writes a steering message's lines with the colored user prefix (shared by history rendering and the streaming renderer). */ +function writeSteeringLines(out: NodeJS.WritableStream, text: string, t: Messages): void { + for (const line of text.split("\n")) { + out.write(`${MAGENTA}${t.steerLinePrefix()}${line}${RESET}\n`); + } +} + /** * Streaming renderer: writes the OmniMessage stream to the output stream. The display * text for tool calls is decided locally by `tool-render.ts`; it no longer accepts a @@ -246,6 +265,13 @@ export class StreamRenderer { private holder: string | null = null; /** Awaiting user input (approval prompt): locks the screen, all messages queue up. */ private promptActive = false; + /** + * The user is composing an input line mid-run (chat REPL): streaming output must not + * scribble over the half-typed line, so rendering is held (messages queue up) until the + * line is submitted or cleared (see `setInputHold`). Independent of the approval-prompt + * lock — approval answers use `beginUserPrompt`/`endUserPrompt`. + */ + private inputHold = false; /** Key of the call the current interactive prompt belongs to (the tool_call passed to beginUserPrompt); null = unattached. */ private promptKey: string | null = null; /** @@ -467,6 +493,29 @@ export class StreamRenderer { this.lastLineKey = key; } + /** + * Chat REPL typing hold: while the user is composing a line mid-run, hold rendering so + * streamed output doesn't scribble over the input; releasing flushes everything queued in + * the meantime. Idempotent; `endTask` force-releases it as a safety net. + */ + setInputHold(active: boolean): void { + if (this.inputHold === active) return; + this.inputHold = active; + if (!active) this.drain(); + } + + /** + * Writes one standalone line through the renderer at the current position (finishing any + * open streamed line first). Meant for host notices tied to the input flow — e.g. the chat + * REPL's steering acknowledgment — printed while the screen is held so they don't + * interleave with streamed output. + */ + printLine(text: string): void { + this.finishLine(); + this.out.write(`${text}\n`); + this.lastLineKey = null; + } + /** User interaction ends: unlocks the screen, first renders approval results deferred during the lock, then drains the queue. */ endUserPrompt(): void { this.promptActive = false; @@ -512,7 +561,7 @@ export class StreamRenderer { if (this.draining) return; this.draining = true; try { - while (!this.promptActive && this.pending.length > 0) { + while (!this.promptActive && !this.inputHold && this.pending.length > 0) { if (this.holder === null) { const msg = this.pending.shift()!; const owner = this.streamOwner(msg); @@ -527,7 +576,7 @@ export class StreamRenderer { const keep: OmniMessage[] = []; let progressed = false; for (let i = 0; i < this.pending.length; i++) { - if (this.promptActive || this.holder === null) { + if (this.promptActive || this.inputHold || this.holder === null) { keep.push(...this.pending.slice(i)); break; } @@ -585,6 +634,21 @@ export class StreamRenderer { this.toolNames.set(p.tool_call_id, p.name); return; } + case "text": { + // Another exception: a mid-run steering message ([user_steering]-wrapped user text + // delivered between turns) has no streamed copy — it is rendered here, in the + // steering style. + const p = payload as TextPayload; + if (p.role === "user") { + const steering = parseUserSteeringText(p.text); + if (steering !== null) { + this.finishLine(); + writeSteeringLines(this.out, steering, this.t); + this.lastLineKey = null; + } + } + return; + } default: return; } @@ -628,7 +692,7 @@ export class StreamRenderer { this.lastLineKey = null; } else if (payload.type === "request_begin") { // The previous request ended in timeout/malformed -> this request is a retry - // carrying : printed when the retry **actually starts** (when + // carrying [turn_retried]: printed when the retry **actually starts** (when // retries are exhausted, there's no retry after the last failure, only an abort // explaining why). if (this.pendingRetry) { @@ -935,6 +999,7 @@ export class StreamRenderer { endTask(elapsedMs = 0): void { this.promptActive = false; this.promptKey = null; + this.inputHold = false; this.flushDeferredDecisions(); this.holder = null; this.drain(); diff --git a/packages/cli/test/render.test.ts b/packages/cli/test/render.test.ts index 816f2b2..07c7092 100644 --- a/packages/cli/test/render.test.ts +++ b/packages/cli/test/render.test.ts @@ -13,6 +13,7 @@ import { toolCallOutput, tokenUsage, sessionMeta, + userText, partialText, partialThinking, partialToolCall, @@ -882,3 +883,61 @@ describe("renderHistory (resume)", () => { expect(text()).toBe(""); }); }); + +describe("mid-run steering rendering ([user_steering] user messages)", () => { + it("streaming: a complete [user_steering] user text renders as prefixed steering lines (other complete texts stay unrendered)", () => { + const { stream, text } = collector(); + const r = new StreamRenderer(stream, t); + r.handle(userText("[user_steering]\nfocus on tests\nand docs\n[/user_steering]")); + r.handle(userText("a plain prompt")); // normal prompts are local echoes: never re-rendered + r.handle(assistantText("complete assistant text")); // complete assistant text: already streamed + const plain = stripAnsi(text()); + expect(plain).toContain("↪ user: focus on tests"); + expect(plain).toContain("↪ user: and docs"); + expect(plain).not.toContain("[user_steering]"); + expect(plain).not.toContain("a plain prompt"); + expect(plain).not.toContain("complete assistant text"); + }); + + it("renderHistory: steering user texts render with the steering prefix, not as a prompt line", () => { + const { stream, text } = collector(); + renderHistory( + [userText("run the tests"), userText("[user_steering]\nswitch branch\n[/user_steering]")], + stream, + t, + ); + const plain = stripAnsi(text()); + expect(plain).toContain("> run the tests"); + expect(plain).toContain("↪ user: switch branch"); + expect(plain).not.toContain("> [user_steering]"); + }); + + it("setInputHold: rendering is held while the user composes a line and flushes on release; printLine lands before the flush", () => { + const { stream, text } = collector(); + const r = new StreamRenderer(stream, t); + r.setInputHold(true); + r.handle(partialText("start", "")); + r.handle(partialText("delta", "streamed while typing")); + expect(stripAnsi(text())).not.toContain("streamed while typing"); + // The steering ack prints immediately (through the renderer, ahead of held output). + r.printLine("» steering queued: do it"); + expect(stripAnsi(text())).toContain("» steering queued: do it"); + expect(stripAnsi(text())).not.toContain("streamed while typing"); + r.setInputHold(false); + expect(stripAnsi(text())).toContain("streamed while typing"); + expect(stripAnsi(text()).indexOf("» steering queued")).toBeLessThan( + stripAnsi(text()).indexOf("streamed while typing"), + ); + }); + + it("endTask force-releases the input hold (safety net)", () => { + const { stream, text } = collector(); + const r = new StreamRenderer(stream, t); + r.setInputHold(true); + r.handle(partialText("start", "")); + r.handle(partialText("delta", "tail output")); + r.handle(partialText("stop")); + r.endTask(10); + expect(stripAnsi(text())).toContain("tail output"); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 1a07787..8b811d0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,6 +18,10 @@ "types": "./dist/omnimessage/index.d.ts", "import": "./dist/omnimessage/index.js" }, + "./markers": { + "types": "./dist/omnimessage/markers/index.d.ts", + "import": "./dist/omnimessage/markers/index.js" + }, "./interfaces": { "types": "./dist/interfaces.d.ts", "import": "./dist/interfaces.js" diff --git a/packages/core/src/engine/context-engine.ts b/packages/core/src/engine/context-engine.ts index 3640791..0287494 100644 --- a/packages/core/src/engine/context-engine.ts +++ b/packages/core/src/engine/context-engine.ts @@ -45,6 +45,19 @@ import { toolCallOutput, userText, } from "../omnimessage/index.js"; +import { + buildContextSummaryText, + buildTurnAbortedBlock, + extractSummary, + buildTurnRetriedBlock, + transcribeText, + transcribeThinking, + transcribeToolCall, + transcribeToolCallOutput, + transcribeUserInput, + unwrapSyntheticBlock, + userSteeringText, +} from "../omnimessage/markers/index.js"; import type { ApprovalDecision, CompactionMode, @@ -243,6 +256,21 @@ export class ContextEngine { * compaction, we don't create an empty file containing only session_meta. */ private pendingTraceRotation = false; + /** + * Steering queue: user messages sent mid-run (`steer`). Drained at every next-input + * assembly — after each turn (delivered as standalone `[user_steering]` user messages + * alongside that turn's tool outputs, or alone as the continuation input when the turn + * produced no tool calls) and after a completed mid-run compaction (so steering that + * arrived during the compaction request is delivered, never swallowed). Only accepts + * entries while a run is in flight; the queue is discarded **only when the run exits** + * — abort, LLM failure, or a plain race with completion (the abort event / task end + * already hands control back to the user, and silently replaying stale steering into a + * later Task would be more surprising than losing it; hosts get `steer() === false` + * after that point and fall back to a normal task). + */ + private steeringQueue: string[] = []; + /** Whether a `run` is currently in flight (gates `steer`; compaction does not count). */ + private taskRunning = false; constructor(private readonly deps: ContextEngineDeps) { this.maxTurns = deps.maxTurns ?? 100; @@ -269,6 +297,55 @@ export class ContextEngine { * Docs: /docs/agent-loop § "The loop at a glance". */ async *run(newMessages: OmniMessage[], opts?: RunOptions): AsyncGenerator { + // Steering window: only while this generator is being driven. The finally also covers + // abort/failure exits — anything still queued is discarded (see steeringQueue). + this.taskRunning = true; + try { + yield* this.runToCompletion(newMessages, opts); + } finally { + this.taskRunning = false; + this.steeringQueue = []; + } + } + + /** + * Queues a steering message for the running Task: it is delivered with the next request + * input as a standalone `[user_steering]` user message — alongside that turn's tool + * outputs, or alone as the continuation input when the turn produced no tool calls. + * Returns false when no Task is running (the host should then submit the text as a + * normal task instead). + */ + steer(text: string): boolean { + if (!this.taskRunning) return false; + this.steeringQueue.push(text); + return true; + } + + /** + * Drains the steering queue into standalone `[user_steering]` user messages (one per + * queued text, in arrival order), yielding each to the output stream and writing it to + * Trace — steering is real user input: unlike a normal Prompt (which the render layer + * already holds locally) this text never reached the consumer, and replay attributes it + * positionally to the next turn's input like any other user message. Returns the messages + * for the caller to append to the next request input; an empty queue is a no-op. + */ + private async *deliverSteering(): AsyncGenerator { + if (this.steeringQueue.length === 0) return []; + const drained = this.steeringQueue; + this.steeringQueue = []; + const messages = drained.map((text) => userText(userSteeringText(text))); + for (const msg of messages) { + yield msg; + await this.write(msg); + } + return messages; + } + + /** The actual Task loop behind `run` (split out so run's finally can close the steering window on every exit path). */ + private async *runToCompletion( + newMessages: OmniMessage[], + opts?: RunOptions, + ): AsyncGenerator { const signal = opts?.signal; // Default approval policy: deny (conservative). CLI/Web will inject a real callback (interactive or permission-mode based). const approve: ApproveFn = opts?.approve ?? (async () => "deny"); @@ -332,9 +409,9 @@ export class ContextEngine { // This turn's input. A timeout/malformed attempt is never committed to history by // AgentHub (an abnormally interrupted stream doesn't land in history), so reconnect - // resends this turn's input unchanged, appending a `` block carrying what + // resends this turn's input unchanged, appending a `[turn_retried]` block carrying what // the failed attempt already produced — the model continues from there instead of - // re-running tools; the tag is distinct from the user-interruption ``. + // re-running tools; the tag is distinct from the user-interruption `[turn_aborted]`. const failedTurns: TurnResult[] = []; let attemptInput = nextInput; let reconnects = 0; @@ -392,9 +469,10 @@ export class ContextEngine { if (compactionReason) { const mode = this.deps.compaction!.mode; if (mode === "discard") { - // Once discarded, the current Task can't continue: if mid-Task, defer until this - // Task ends. - if (!midTask) { + // Once discarded, the current Task can't continue: defer until the Task really + // ends (mid-Task, or steering still queued that must continue the loop). The + // queue is only peeked here — delivery happens at the input assembly below. + if (!midTask && this.steeringQueue.length === 0) { yield* this.discardContext(compactionReason); return; } @@ -406,7 +484,8 @@ export class ContextEngine { ); if (result.status === "aborted") { // User interrupted compaction: keep the original context; if mid-Task, hold the - // tool outputs as carry-over per case A. + // tool outputs as carry-over per case A. Abort is the one path that discards the + // steering queue (run's finally — control goes back to the user). if (midTask) { this.pendingCarryOver = this.buildCarryOver(attemptInput, turn); yield* this.emitAbort("aborted during compaction"); @@ -414,7 +493,10 @@ export class ContextEngine { return; } if (result.status === "completed") { - if (!midTask) { + // Boundary check against the **live** queue: steering may have arrived during + // the multi-second compaction request and must not be swallowed (no await sits + // between this check and the return, so the window cannot reopen). + if (!midTask && this.steeringQueue.length === 0) { // Task boundary: the summary is merged with the next user Prompt as the new // context's first input. this.pendingSummary = result.summary!; @@ -423,9 +505,12 @@ export class ContextEngine { // Mid-Task: the summary itself becomes the new LLM object's first input (this // turn's tool results were already folded into the compaction request and absorbed // into the summary); continuation relies on the model's own next-step plan written - // into the summary, with no hardcoded continuation instruction appended. + // into the summary, with no hardcoded continuation instruction appended. Queued + // steering (including anything that arrived during the compaction request) is + // delivered right after the summary as standalone [user_steering] user turns. await this.write(result.summary!); - nextInput = [result.summary!]; + const steering = yield* this.deliverSteering(); + nextInput = [result.summary!, ...steering]; continue; } // failed: keep the original context and Trace index; the current Task continues and @@ -433,10 +518,15 @@ export class ContextEngine { } } - // No tool_call this turn -> the Task ends (the final reply has already been streamed out). - if (!midTask) return; - // Otherwise continue, using the tool outputs as the next turn's LLM input. - nextInput = turn.toolOutputs; + // Next-input assembly — the steering delivery point: everything queued so far becomes + // standalone [user_steering] user messages riding alongside this turn's tool outputs + // (or alone as the continuation input when the turn produced no tool calls, instead of + // ending the Task — subject to the max-turns guard at the top of the loop). + const steering = yield* this.deliverSteering(); + // No tool_call this turn and no steering left -> the Task ends (the final reply has + // already been streamed out). + if (!midTask && steering.length === 0) return; + nextInput = [...turn.toolOutputs, ...steering]; } } @@ -815,7 +905,7 @@ export class ContextEngine { /** * `summarize` compaction: appends the compaction Prompt to the **old** LLM object (first * folding in all of this turn's tool results when mid-Task, to keep tool_use/tool_result - * pairing), then extracts the `` and wraps it as `` user text. The + * pairing), then extracts the `[summary]` and wraps it as `[context_summary]` user text. The * compaction request's streamed output is not pushed to the Human output stream (it emits * paired compaction events, plus the compaction request's `token_usage` — positioned between * the two events, so the frontend can count compaction cost into its stats), but it is written @@ -854,11 +944,9 @@ export class ContextEngine { // the frontend uses this to count compaction cost into stats and display it on the // compaction-complete line. if (attempt.usage) yield attempt.usage; - // Lenient extraction: if the output lacks a tag, use the entire compaction + // Lenient extraction: if the output lacks a [summary] tag, use the entire compaction // output as-is rather than treating it as a failure. - const summary = userText( - `\n${extractSummary(attempt.text)}\n`, - ); + const summary = userText(buildContextSummaryText(extractSummary(attempt.text))); yield* this.emitCompactionEnd(reason, "summarize", "completed"); await this.startNewContext(); return { status: "completed", summary }; @@ -974,15 +1062,15 @@ export class ContextEngine { * Builds the interruption resend content (carry-over, interruption cleanup) * based on the LLM's terminal state. Used only for the **exit** cleanup of * aborted / failed (reconnect retry doesn't go through here — retry input is assembled by - * withRetriedTurns, appending `` with the failed attempt's output, distinct - * from the user-interruption ``): + * withRetriedTurns, appending `[turn_retried]` with the failed attempt's output, distinct + * from the user-interruption `[turn_aborted]`): * - Model output completed (case A, outcome=completed): AgentHub already committed an * assistant turn containing `tool_call`, so it can only be resent as a structured * `tool_call_output` to pair with it (cannot flatten, or the already-committed tool_call * would be left unanswered and rejected). * - Model output incomplete (case B): the `tool_call_output` in this turn's input (paired * with the previous completed turn) is kept as-is; the text input and this turn's - * thinking/text/tool call/result are flattened into a single `` plain-text + * thinking/text/tool call/result are flattened into a single `[turn_aborted]` plain-text * user message. * Docs: /docs/agent-loop § "Interruption and carry-over". */ @@ -1022,13 +1110,13 @@ export class ContextEngine { * Case B: flattens this attempt's input and its produced content into carry-over. Structured * `tool_call_output` in the input (paired with the previous completed turn) is kept as-is; * everything else (text input, model thinking/text, this attempt's tool calls/results) is - * transcribed into a single `` plain-text user message (includes all + * transcribed into a single `[turn_aborted]` plain-text user message (includes all * completed and incomplete messages, including partial thinking/text). If the input text is - * itself already a `` block (from a previous attempt or a previous run's + * itself already a `[turn_aborted]` block (from a previous attempt or a previous run's * carry-over), its content is unwrapped and merged in, keeping a single-level structure. * * TODO(multimodal): only text input is currently kept — `image_url` / `inline_data` input is - * lost during flatten (the `` structure has no corresponding transcription yet); + * lost during flatten (the `[turn_aborted]` structure has no corresponding transcription yet); * multimodal carry-over support to be added later. */ private flattenCarryOver( @@ -1050,35 +1138,34 @@ export class ContextEngine { return [...structured, flattened]; } - /** Transcribes the interrupted turn's input, model thinking/text, and tool calls/results into a single `` plain-text block. */ + /** Transcribes the interrupted turn's input, model thinking/text, and tool calls/results into a single `[turn_aborted]` plain-text block. */ private buildTurnAbortedText( textInputs: OmniMessage[], assistantSegments: OmniMessage[], toolCalls: OmniMessage[], toolOutputs: OmniMessage[], ): string { - const lines: string[] = [""]; + const lines: string[] = []; for (const m of textInputs) { const t = (m.payload as TextPayload).text; - // If this text is itself already a synthetic block — a previous run's ``, - // or this turn's reconnect-appended `` — extract its inner lines and merge + // If this text is itself already a synthetic block — a previous run's `[turn_aborted]`, + // or this turn's reconnect-appended `[turn_retried]` — extract its inner lines and merge // them in directly, avoiding layered nesting / unbounded growth (keeping a single-level // structure). const inner = unwrapSyntheticBlock(t); if (inner !== null) { if (inner) lines.push(inner); } else { - lines.push(` ${t}`); + lines.push(transcribeUserInput(t)); } } lines.push(...transcribeTurnLines(assistantSegments, toolCalls, toolOutputs)); - lines.push(""); - return lines.join("\n"); + return buildTurnAbortedBlock(lines); } /** * Assembles the reconnect retry input: the original input is kept as-is (structure and - * multimodal content preserved), with a `` text appended at the end carrying + * multimodal content preserved), with a `[turn_retried]` text appended at the end carrying * each failed attempt's thinking/text and tool calls/results produced so far; if nothing has * been produced yet, it's just the original input. The synthetic message is sent to the model * only and not written to Trace (same rule as flatten carry-over). @@ -1089,7 +1176,7 @@ export class ContextEngine { transcribeTurnLines(t.assistantSegments, t.toolCalls, t.toolOutputs), ); if (lines.length === 0) return input; - return [...input, userText(["", ...lines, ""].join("\n"))]; + return [...input, userText(buildTurnRetriedBlock(lines))]; } /** @@ -1118,19 +1205,7 @@ export class ContextEngine { } } -/** - * If the text is an engine-synthesized whole block (`` or ``), - * strips the outer tags and returns the inner lines (may be an empty string); otherwise returns - * null. Both kinds of synthetic blocks use identical inner markup (thinking/text/tool_call/ - * tool_call_output), so it can be merged directly into a new block while keeping a single-level - * structure. - */ -function unwrapSyntheticBlock(text: string): string | null { - const m = /^<(turn_aborted|turn_retried)>\n?([\s\S]*?)\n?<\/\1>\s*$/.exec(text); - return m ? m[2]! : null; -} - -/** Transcribes the model's produced thinking/text and tool calls/results into tagged lines (shared by ``/``). */ +/** Transcribes the model's produced thinking/text and tool calls/results into tagged lines (shared by `[turn_aborted]`/`[turn_retried]`). */ function transcribeTurnLines( assistantSegments: OmniMessage[], toolCalls: OmniMessage[], @@ -1142,31 +1217,18 @@ function transcribeTurnLines( for (const seg of assistantSegments) { const p = seg.payload as { type?: string }; if (p.type === "thinking") { - lines.push(` ${(seg.payload as ThinkingPayload).thinking}`); + lines.push(transcribeThinking((seg.payload as ThinkingPayload).thinking)); } else if (p.type === "text") { - lines.push(` ${(seg.payload as TextPayload).text}`); + lines.push(transcribeText((seg.payload as TextPayload).text)); } } for (const tc of toolCalls) { const p = tc.payload; - lines.push(` ${p.arguments}`); + lines.push(transcribeToolCall(p.name, p.tool_call_id, p.arguments)); } for (const out of toolOutputs) { const p = out.payload as ToolCallOutputPayload; - lines.push( - ` ${p.output}`, - ); + lines.push(transcribeToolCallOutput(p.tool_call_id, p.stop_reason ?? "completed", p.output)); } return lines; } - -/** - * Extracts the summary within `` from compaction output; when the tag is - * missing, leniently uses the entire output as-is (not treated as a failure). Also used by - * Session resumption's "compaction closure" replay to - * reconstruct the `` pending input from the old Trace's compaction output. - */ -export function extractSummary(raw: string): string { - const match = /([\s\S]*?)<\/summary>/.exec(raw); - return (match ? match[1]! : raw).trim(); -} diff --git a/packages/core/src/internal/session-title.ts b/packages/core/src/internal/session-title.ts index e23ab67..babaa0d 100644 --- a/packages/core/src/internal/session-title.ts +++ b/packages/core/src/internal/session-title.ts @@ -13,6 +13,7 @@ * by the barrel; the prompt/request internals are not. */ import { userText } from "../omnimessage/index.js"; +import { TITLE_NOISE_TAGS, stripMarkerBlocks } from "../omnimessage/markers/index.js"; import type { OmniMessage, TextPayload, @@ -27,27 +28,15 @@ const EXCERPT_MAX_CHARS = 2000; const TITLE_MAX_CHARS = 30; /** - * Special message markers that must never leak into a title. These are machine-inserted - * XML-ish blocks (a skill invocation wraps the body in `…`, a - * subagent handoff / scheduled task / model switch prepend their own blocks) — meaningful - * to the runtime, noise in a title. The list is a fixed allowlist so ordinary angle-bracket - * text (e.g. a user pasting `
`) is left untouched. - */ -const MARKER_TAGS = ["use_skills", "handoff_from", "scheduled_task", "model_switch_from"]; - -/** - * Strips machine-inserted marker blocks (see MARKER_TAGS) from conversation text so titles are - * built from the human-meaningful body only — both the material sent to the model and the - * fallback derived from the raw first message. Removes the paired `…` block (any - * inner content, across lines) and any stray unpaired `` / `` left behind. + * Strips machine-inserted marker blocks from conversation text so titles are built from the + * human-meaningful body only — both the material sent to the model and the fallback derived + * from the raw first message. The tag list (TITLE_NOISE_TAGS) and the both-forms stripping + * live in the markers module; engine-synthesized blocks are deliberately not stripped (they + * are never title material). */ export function stripConversationMarkers(text: string): string { let out = text; - for (const tag of MARKER_TAGS) { - out = out - .replace(new RegExp(`<${tag}>[\\s\\S]*?`, "g"), "") - .replace(new RegExp(``, "g"), ""); - } + for (const tag of TITLE_NOISE_TAGS) out = stripMarkerBlocks(out, tag); return out.trim(); } diff --git a/packages/core/src/omnimessage/index.ts b/packages/core/src/omnimessage/index.ts index ee2d6f7..d96736b 100644 --- a/packages/core/src/omnimessage/index.ts +++ b/packages/core/src/omnimessage/index.ts @@ -1,3 +1,6 @@ export * from "./types.js"; export * from "./builders.js"; export * from "./aggregate.js"; +// Special message markers ([user_steering], [use_skills], [handoff_from], …): one module, +// also reachable directly as `@prismshadow/penguin-core/markers`. +export * from "./markers/index.js"; diff --git a/packages/core/src/omnimessage/markers/block.ts b/packages/core/src/omnimessage/markers/block.ts new file mode 100644 index 0000000..d80b734 --- /dev/null +++ b/packages/core/src/omnimessage/markers/block.ts @@ -0,0 +1,73 @@ +/** + * Marker block primitives — the one place that knows how a special message marker is + * *spelled*. + * + * Canonical form is `[tag]…[/tag]`. The earlier angle-bracket form `…` is still + * **recognized by every parser** and must stay recognized indefinitely: markers are persisted + * in Traces (re-rendered later) and in each Agent's `system_config.yaml` compaction prompt + * (old agents keep instructing the model to use ``). Producers emit the square form + * only — that asymmetry is the whole compatibility policy, and it lives here rather than being + * re-derived at each call site. + */ + +/** Escapes a tag name for embedding in a RegExp (tags are `[a-z_]`, but keep this honest). */ +function escapeTag(tag: string): string { + return tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Wraps `body` in the canonical paired form: `[tag]\n…\n[/tag]` (newlines around the body). */ +export function markerBlock(tag: string, body: string): string { + return `[${tag}]\n${body}\n[/${tag}]`; +} + +/** Opening/closing delimiters of the canonical form (for prefix checks and inline tags). */ +export function markerOpen(tag: string): string { + return `[${tag}]`; +} + +export function markerClose(tag: string): string { + return `[/${tag}]`; +} + +/** + * A pair of regexes for one tag — the current square form and the legacy angle form — built + * from a single source pattern. `body` is the sub-pattern placed between the tags (capture + * groups inside it keep their order), `flags` are applied to both. + * + * Callers match square first and fall back to angle (`matchDualForm`), so a message mixing + * both resolves to the current form. + */ +export function dualFormPatterns(tag: string, body: string, flags = ""): [RegExp, RegExp] { + const t = escapeTag(tag); + return [ + new RegExp(`\\[${t}\\]${body}\\[/${t}\\]`, flags), + new RegExp(`<${t}>${body}`, flags), + ]; +} + +/** Runs the square-form pattern first, then the legacy angle form; returns the first match (or null). */ +export function matchDualForm( + patterns: readonly [RegExp, RegExp], + text: string, +): RegExpExecArray | null { + return patterns[0].exec(text) ?? patterns[1].exec(text); +} + +/** + * Strips every occurrence of one tag's block from `text` (both forms, across lines), plus any + * stray unpaired opening/closing tag left behind. Used by title generation, where a leaked + * marker must never become the title. + */ +export function stripMarkerBlocks(text: string, tag: string): string { + const t = escapeTag(tag); + return text + .replace(new RegExp(`\\[${t}\\][\\s\\S]*?\\[/${t}\\]`, "g"), "") + .replace(new RegExp(`\\[/?${t}\\]`, "g"), "") + .replace(new RegExp(`<${t}>[\\s\\S]*?`, "g"), "") + .replace(new RegExp(``, "g"), ""); +} + +/** True when `text` starts with this marker's opening tag in either form (prefix detection, e.g. context_summary). */ +export function startsWithMarker(text: string, tag: string): boolean { + return text.startsWith(`[${tag}]`) || text.startsWith(`<${tag}>`); +} diff --git a/packages/core/src/omnimessage/markers/engine-blocks.ts b/packages/core/src/omnimessage/markers/engine-blocks.ts new file mode 100644 index 0000000..753b58c --- /dev/null +++ b/packages/core/src/omnimessage/markers/engine-blocks.ts @@ -0,0 +1,102 @@ +/** + * Engine-synthesized marker blocks: the interruption/retry transcripts and the compaction + * summary. These are produced by the ReAct loop (and rebuilt by Trace replay), never by a + * host — but their spelling belongs here with the rest of the markers so the engine and the + * resume path cannot drift apart (they used to duplicate the `[context_summary]` template). + */ +import { dualFormPatterns, markerBlock, matchDualForm } from "./block.js"; +import { MARKER_TAGS, TRANSCRIPT_TAGS } from "./tags.js"; + +/** Wraps a compaction summary as the new context's first input: `[context_summary]…[/context_summary]`. */ +export function buildContextSummaryText(summary: string): string { + return markerBlock(MARKER_TAGS.contextSummary, summary); +} + +const SUMMARY_PATTERNS = dualFormPatterns(MARKER_TAGS.summary, "([\\s\\S]*?)"); + +/** + * Extracts the summary the model wrote inside `[summary]…[/summary]` during compaction; when + * the tag is missing, leniently uses the entire output as-is (not treated as a failure). + * + * The legacy `` form must stay accepted **indefinitely**: + * `compaction.prompt` is persisted in every existing agent's `system_config.yaml`, so old + * agents keep instructing the model to use the angle-bracket tags. + */ +export function extractSummary(raw: string): string { + const match = matchDualForm(SUMMARY_PATTERNS, raw); + return (match ? match[1]! : raw).trim(); +} + +/** One transcribed line inside a turn block: an inner tag, optional attributes, and the body. */ +function transcriptLine(tag: string, body: string, attrs = ""): string { + return ` [${tag}${attrs}]${body}[/${tag}]`; +} + +/** `[user_input]…[/user_input]` — a text input of the interrupted turn. */ +export function transcribeUserInput(text: string): string { + return transcriptLine(TRANSCRIPT_TAGS.userInput, text); +} + +/** `[thinking]…[/thinking]` — a thinking segment the model produced before the interruption. */ +export function transcribeThinking(thinking: string): string { + return transcriptLine(TRANSCRIPT_TAGS.thinking, thinking); +} + +/** `[text]…[/text]` — a text segment the model produced before the interruption. */ +export function transcribeText(text: string): string { + return transcriptLine(TRANSCRIPT_TAGS.text, text); +} + +/** `[tool_call name=… id=…]arguments[/tool_call]` — a tool call already issued. */ +export function transcribeToolCall(name: string, toolCallId: string, args: string): string { + return transcriptLine(TRANSCRIPT_TAGS.toolCall, args, ` name="${name}" id="${toolCallId}"`); +} + +/** `[tool_call_output id=… status=…]output[/tool_call_output]` — a tool result already produced. */ +export function transcribeToolCallOutput( + toolCallId: string, + status: string, + output: string, +): string { + return transcriptLine( + TRANSCRIPT_TAGS.toolCallOutput, + output, + ` id="${toolCallId}" status="${status}"`, + ); +} + +/** Wraps transcribed lines as the interruption block resent as carry-over. */ +export function buildTurnAbortedBlock(lines: readonly string[]): string { + return [`[${MARKER_TAGS.turnAborted}]`, ...lines, `[/${MARKER_TAGS.turnAborted}]`].join("\n"); +} + +/** Wraps transcribed lines as the reconnect-retry block appended to the resent input. */ +export function buildTurnRetriedBlock(lines: readonly string[]): string { + return [`[${MARKER_TAGS.turnRetried}]`, ...lines, `[/${MARKER_TAGS.turnRetried}]`].join("\n"); +} + +/** + * Both synthetic turn blocks share one matcher (a backreference keeps the tags paired): the + * current square form first, then the legacy angle form — resume replays a discarded turn's + * original input as-is, so text written to a Trace before the marker format changed can still + * flow back in. + */ +const SYNTHETIC_BLOCK_PATTERNS: readonly [RegExp, RegExp] = [ + new RegExp( + `^\\[(${MARKER_TAGS.turnAborted}|${MARKER_TAGS.turnRetried})\\]\\n?([\\s\\S]*?)\\n?\\[/\\1\\]\\s*$`, + ), + new RegExp( + `^<(${MARKER_TAGS.turnAborted}|${MARKER_TAGS.turnRetried})>\\n?([\\s\\S]*?)\\n?\\s*$`, + ), +]; + +/** + * If the text is an engine-synthesized whole block (`[turn_aborted]` or `[turn_retried]`), + * strips the outer tags and returns the inner lines (possibly an empty string); otherwise + * null. Both kinds use identical inner markup, so the content can be merged straight into a + * new block, keeping a single-level structure. + */ +export function unwrapSyntheticBlock(text: string): string | null { + const m = matchDualForm(SYNTHETIC_BLOCK_PATTERNS, text); + return m ? m[2]! : null; +} diff --git a/packages/core/src/omnimessage/markers/index.ts b/packages/core/src/omnimessage/markers/index.ts new file mode 100644 index 0000000..38b241e --- /dev/null +++ b/packages/core/src/omnimessage/markers/index.ts @@ -0,0 +1,27 @@ +/** + * Special message markers — one module for every marker the product writes into message text. + * + * A marker is a block of the form `[tag]…[/tag]` embedded in an OmniMessage's text: it tells + * the model (and the render layers) that this part of the conversation is machine-inserted + * rather than something the user typed. They come in three families: + * + * - **engine blocks** (`engine-blocks.ts`): `[turn_aborted]` / `[turn_retried]` transcripts, + * the `[context_summary]` injection and the `[summary]` the compaction prompt asks for — + * produced by the ReAct loop and Trace replay; + * - **origin blocks** (`origin-blocks.ts`): `[use_skills]`, `[handoff_from]`, + * `[scheduled_task]`, `[model_switch_from]` — prefixed to a user message by the hosts + * (Web composer, server scheduler) and collapsed into a banner when rendered; + * - **steering** (`steering.ts`): `[user_steering]`, a mid-run user message. + * + * `block.ts` owns the spelling itself — the canonical square form for producers and the + * dual-form (square + legacy angle) matching every parser applies, because markers persist in + * Traces and in each agent's stored compaction prompt. `tags.ts` owns the tag list. + * + * Everything here is pure string work: no OmniMessage envelopes, no I/O — the callers wrap + * the result in `userText(...)` (or match against a payload's text) themselves. + */ +export * from "./block.js"; +export * from "./tags.js"; +export * from "./engine-blocks.js"; +export * from "./origin-blocks.js"; +export * from "./steering.js"; diff --git a/packages/core/src/omnimessage/markers/origin-blocks.ts b/packages/core/src/omnimessage/markers/origin-blocks.ts new file mode 100644 index 0000000..8de9732 --- /dev/null +++ b/packages/core/src/omnimessage/markers/origin-blocks.ts @@ -0,0 +1,256 @@ +/** + * Origin blocks — marker blocks that **prefix a user message** to say where it came from or + * what it should use, produced by the hosts (Web composer, server scheduler) and collapsed + * into a banner by the render layers. + * + * Each marker gets a `build*` producer (canonical square form) and a `parse*` inverse that + * also accepts the legacy angle form, so messages persisted in older Traces still render as + * banners rather than raw markers. The block's field lines are `key: value`; free-text + * explanation lines are ignored by the parsers. + */ +import { dualFormPatterns, markerBlock, matchDualForm } from "./block.js"; +import { MARKER_TAGS } from "./tags.js"; + +// --------------------------------------------------------------------------- +// [use_skills] — skill invocation prefixed to the user's message +// --------------------------------------------------------------------------- + +/** Parsed `[use_skills]` block: the selected skill names plus the message body after it. */ +export interface SkillsMessage { + skills: string[]; + rest: string; +} + +/** Builds a message body with a `[use_skills]` block: an empty list omits the block; with no body text only the block is returned (no trailing blank line). */ +export function buildSkillsMessage(names: string[], text: string): string { + if (names.length === 0) return text; + const block = markerBlock(MARKER_TAGS.useSkills, `skills: ${names.join(", ")}`); + return text ? `${block}\n\n${text}` : block; +} + +const SKILLS_PATTERNS = dualFormPatterns(MARKER_TAGS.useSkills, "\\nskills: ([^\\n]+)\\n"); + +/** + * Inverse of `buildSkillsMessage`: recognizes a block only at **the start of the message**, + * returning the skill names and the remaining body (a block appearing mid-body is plain + * text). The `skills:` line is split on commas and trimmed; an empty list is not a block. + */ +export function parseSkillsMessage(text: string): SkillsMessage | null { + const m = matchDualForm(SKILLS_PATTERNS, text); + if (!m || m.index !== 0) return null; + const skills = m[1]! + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (skills.length === 0) return null; + return { skills, rest: text.slice(m[0].length).replace(/^\n+/, "") }; +} + +// --------------------------------------------------------------------------- +// Field-line helpers shared by the origin blocks below +// --------------------------------------------------------------------------- + +/** Splits a `value` of the shape `id (label)` into its parts (label undefined when absent). */ +function splitLabeled(value: string): { id: string; label?: string } { + const m = /^([\w-]+) \((.*)\)$/.exec(value); + return m ? { id: m[1]!, label: m[2]! } : { id: value }; +} + +/** Iterates the `key: value` field lines of a block body, ignoring prose lines. */ +function fieldLines(body: string, keys: readonly string[]): Array<[string, string]> { + const pattern = new RegExp(`^(${keys.join("|")}): (.+)$`); + const out: Array<[string, string]> = []; + for (const line of body.split("\n")) { + const kv = pattern.exec(line); + if (kv) out.push([kv[1]!, kv[2]!]); + } + return out; +} + +// --------------------------------------------------------------------------- +// [handoff_from] — @-mention handoff into a new conversation +// --------------------------------------------------------------------------- + +/** Origin info for an @-handoff new conversation: source agent is always present; the source Session is omitted while it's still a draft. */ +export interface HandoffOrigin { + agentId: string; + agentName?: string; + sessionId?: string; + sessionTitle?: string; + workspace?: string; +} + +/** + * First message of an @-handoff new conversation (English): the `[handoff_from]` block states + * that this conversation was opened by an @ mention and carries the source agent / Session / + * Workspace, so the @-mentioned agent knows its origin (e.g. defaulting to the source agent as + * its working target, or reaching source files via the Workspace path). The parenthetical + * label is omitted when the display name/title equals the id or is absent. + */ +export function buildHandoffMessage(origin: HandoffOrigin): string { + const name = + origin.agentName && origin.agentName !== origin.agentId ? ` (${origin.agentName})` : ""; + const lines = [`agent: ${origin.agentId}${name}`]; + if (origin.sessionId) { + const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : ""; + lines.push(`session: ${origin.sessionId}${title}`); + } + if (origin.workspace) lines.push(`workspace: ${origin.workspace}`); + return markerBlock( + MARKER_TAGS.handoffFrom, + [ + "This conversation was opened by @-mentioning you from another conversation; its origin is listed below and the user's message, if any, follows. When the request refers to an agent, session, or files without naming them, it means this origin.", + ...lines, + ].join("\n"), + ); +} + +const HANDOFF_PATTERNS = dualFormPatterns(MARKER_TAGS.handoffFrom, "\\n([\\s\\S]*)\\n", ""); + +/** + * Inverse of `buildHandoffMessage`: returns origin info when the **whole** message is one + * `[handoff_from]` block, otherwise null (a normal user message renders as-is). + */ +export function parseHandoffMessage(text: string): HandoffOrigin | null { + const trimmed = text.trim(); + const block = matchDualForm(HANDOFF_PATTERNS, trimmed); + if (!block || block.index !== 0 || block[0].length !== trimmed.length) return null; + const origin: HandoffOrigin = { agentId: "" }; + for (const [key, value] of fieldLines(block[1]!, ["agent", "session", "workspace"])) { + if (key === "workspace") { + origin.workspace = value; + continue; + } + const { id, label } = splitLabeled(value); + if (key === "agent") { + origin.agentId = id; + if (label !== undefined) origin.agentName = label; + } else { + origin.sessionId = id; + if (label !== undefined) origin.sessionTitle = label; + } + } + return origin.agentId ? origin : null; +} + +// --------------------------------------------------------------------------- +// [scheduled_task] — scheduled trigger, followed by the task's own Prompt body +// --------------------------------------------------------------------------- + +/** Origin info for a scheduled-task trigger. */ +export interface ScheduledOrigin { + /** Task name (filename minus .toml). */ + name: string; + /** Trigger timestamp (ISO 8601); empty string when absent from the block. */ + firedAt: string; +} + +/** + * Trigger input = a `[scheduled_task]` origin block (task name and fire time) + the prompt + * body: tells the model this was fired by a schedule; the frontend collapses the origin block + * into a one-line schedule hint (Trace shows it verbatim). + */ +export function buildScheduledMessage(name: string, firedAt: string, prompt: string): string { + const block = markerBlock( + MARKER_TAGS.scheduledTask, + [ + "This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.", + `schedule: ${name}`, + `fired_at: ${firedAt}`, + ].join("\n"), + ); + return `${block}\n\n${prompt}`; +} + +const SCHEDULED_PATTERNS = dualFormPatterns(MARKER_TAGS.scheduledTask, "\\n([\\s\\S]*?)\\n"); + +/** + * Inverse of `buildScheduledMessage`: returns origin info and the remaining text when the + * message **starts with** a `[scheduled_task]` block, otherwise null. Unlike handoff, the + * block is followed by the task's Prompt body, which is returned alongside it for normal + * rendering (the raw block isn't shown; the Trace page shows it as-is). + */ +export function parseScheduledMessage( + text: string, +): { origin: ScheduledOrigin; rest: string } | null { + const m = matchDualForm(SCHEDULED_PATTERNS, text); + if (!m || m.index !== 0) return null; + const origin: ScheduledOrigin = { name: "", firedAt: "" }; + for (const [key, value] of fieldLines(m[1]!, ["schedule", "fired_at"])) { + if (key === "schedule") origin.name = value; + else origin.firedAt = value; + } + if (!origin.name) return null; + return { origin, rest: text.slice(m[0].length).replace(/^\n+/, "") }; +} + +// --------------------------------------------------------------------------- +// [model_switch_from] — the /model command's handoff-style conversation switch +// --------------------------------------------------------------------------- + +/** + * Origin info for a `/model` switch new conversation (modeled on HandoffOrigin): the source + * Session, the absolute path of its latest Trace file (the model reads it for the earlier + * history — nothing is injected into the new context), the shared Workspace, and the previous + * model's paired reference. + */ +export interface ModelSwitchOrigin { + sessionId: string; + sessionTitle?: string; + /** Absolute path of the source session's latest Trace file (JSONL of OmniMessage envelopes). */ + tracePath?: string; + workspace?: string; + /** The source session's model reference pair (never concatenated — two separate fields). */ + prevProvider?: string; + prevModelId?: string; +} + +/** + * First message of a `/model` switch new conversation (English, mirroring the handoff block): + * the `[model_switch_from]` block states that this conversation continues an earlier one on a + * different model and carries the source Session / Trace path / Workspace / previous model + * pair. The earlier history is deliberately NOT injected — some models require thinking + * payloads and provider `fidelity` byte-for-byte when history is replayed, which cannot cross + * models — so the model reads the Trace file itself when it needs the context. + */ +export function buildModelSwitchMessage(origin: ModelSwitchOrigin): string { + const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : ""; + const lines = [`session: ${origin.sessionId}${title}`]; + if (origin.tracePath) lines.push(`trace: ${origin.tracePath}`); + if (origin.workspace) lines.push(`workspace: ${origin.workspace}`); + if (origin.prevModelId) lines.push(`previous_model: ${origin.prevModelId}`); + if (origin.prevProvider) lines.push(`previous_provider: ${origin.prevProvider}`); + return markerBlock( + MARKER_TAGS.modelSwitchFrom, + [ + "The user switched models: this conversation continues an earlier conversation, now on a different model. Its origin is listed below and the user's message, if any, follows. The earlier history is NOT in your context — when you need it, read the trace file at the path below (JSONL, one message envelope per line: user/assistant text, tool calls and results).", + ...lines, + ].join("\n"), + ); +} + +const MODEL_SWITCH_PATTERNS = dualFormPatterns(MARKER_TAGS.modelSwitchFrom, "\\n([\\s\\S]*)\\n"); + +/** + * Inverse of `buildModelSwitchMessage`: returns origin info when the **whole** message is one + * `[model_switch_from]` block, otherwise null. The session line's `(title)` label is split + * off; prose lines are ignored. + */ +export function parseModelSwitchMessage(text: string): ModelSwitchOrigin | null { + const trimmed = text.trim(); + const block = matchDualForm(MODEL_SWITCH_PATTERNS, trimmed); + if (!block || block.index !== 0 || block[0].length !== trimmed.length) return null; + const origin: ModelSwitchOrigin = { sessionId: "" }; + const keys = ["session", "trace", "workspace", "previous_model", "previous_provider"]; + for (const [key, value] of fieldLines(block[1]!, keys)) { + if (key === "session") { + const { id, label } = splitLabeled(value); + origin.sessionId = id; + if (label !== undefined) origin.sessionTitle = label; + } else if (key === "trace") origin.tracePath = value; + else if (key === "workspace") origin.workspace = value; + else if (key === "previous_model") origin.prevModelId = value; + else origin.prevProvider = value; + } + return origin.sessionId ? origin : null; +} diff --git a/packages/core/src/omnimessage/markers/steering.ts b/packages/core/src/omnimessage/markers/steering.ts new file mode 100644 index 0000000..e2c8bb1 --- /dev/null +++ b/packages/core/src/omnimessage/markers/steering.ts @@ -0,0 +1,38 @@ +/** + * `[user_steering]` — mid-run user messages delivered between turns. + * + * While a Task is running, the user can send a steering message (`Session.steer`): the engine + * queues it and delivers it as a **standalone user text message** wrapped in a + * `[user_steering]…[/user_steering]` block, sent with the next request input alongside that + * turn's tool outputs (or as the continuation input when the turn produced no tool calls) — + * the model sees it without the agent loop being interrupted. The message is real user input: + * written to Trace like any Prompt and yielded to the output stream. The marker exists so the + * model and the render layers can tell it apart from a task-starting Prompt: UIs keep it inside + * the running Task (no new Task segment) and render it as user speech instead of raw markers. + * + * Unlike the other markers this one has **no legacy angle form** to accept: it was introduced + * after the square-bracket convention, so no Trace can contain ``. + */ +import { markerBlock } from "./block.js"; +import { MARKER_TAGS } from "./tags.js"; + +/** Wraps one queued steering message as the standalone user-text body sent to the model. */ +export function userSteeringText(text: string): string { + return markerBlock(MARKER_TAGS.userSteering, text); +} + +/** Matches a whole user text that is exactly one `[user_steering]` block (square form only, see the module note). */ +const STEERING_TEXT_RE = new RegExp( + `^\\[${MARKER_TAGS.userSteering}\\]\\n([\\s\\S]*?)\\n\\[/${MARKER_TAGS.userSteering}\\]\\s*$`, +); + +/** + * Inverse of `userSteeringText`: when the whole user text is one `[user_steering]` block, + * returns the inner message; otherwise null (a normal user message — including one merely + * mentioning the marker mid-body — is left alone). Used by task segmentation (a steering + * message must not start a new Task) and by the Web/CLI steering rendering. + */ +export function parseUserSteeringText(text: string): string | null { + const m = STEERING_TEXT_RE.exec(text); + return m ? m[1]! : null; +} diff --git a/packages/core/src/omnimessage/markers/tags.ts b/packages/core/src/omnimessage/markers/tags.ts new file mode 100644 index 0000000..54aff79 --- /dev/null +++ b/packages/core/src/omnimessage/markers/tags.ts @@ -0,0 +1,52 @@ +/** + * The canonical marker tag list — every special marker the product writes into message text, + * in one place so producers, parsers and the title stripper cannot drift apart. + * + * All are spelled `[tag]…[/tag]`; see ./block.ts for the form and the legacy-compat policy. + */ + +/** Marker tags that wrap a whole message or a leading block of one. */ +export const MARKER_TAGS = { + /** Interrupted turn, transcribed for resend (engine carry-over). */ + turnAborted: "turn_aborted", + /** Automatic reconnect retry, carrying the failed attempt's partial output (engine). */ + turnRetried: "turn_retried", + /** Compaction summary injected as the new context's first input (engine / resume). */ + contextSummary: "context_summary", + /** The summary the model is asked to write during compaction (compaction prompt / parser). */ + summary: "summary", + /** Mid-run user message delivered between turns (Session.steer). */ + userSteering: "user_steering", + /** Skill invocation block prefixed to a user message (Web composer). */ + useSkills: "use_skills", + /** @-handoff origin block, first message of the delegated conversation (Web). */ + handoffFrom: "handoff_from", + /** Scheduled-task trigger origin block (server scheduler). */ + scheduledTask: "scheduled_task", + /** `/model` switch origin block, first message of the continued conversation (Web). */ + modelSwitchFrom: "model_switch_from", + /** AGENTS.md wrapper inside the system prompt (static template text). */ + developerInstructions: "developer_instructions", +} as const; + +/** Inner tags used inside a transcribed turn block (`[turn_aborted]` / `[turn_retried]`). */ +export const TRANSCRIPT_TAGS = { + userInput: "user_input", + thinking: "thinking", + text: "text", + toolCall: "tool_call", + toolCallOutput: "tool_call_output", +} as const; + +/** + * Machine-inserted marker blocks that must never leak into a generated Session title: the + * blocks that **prefix a user message** (a skill invocation, a handoff / scheduled-task / + * model-switch origin note). Engine-synthesized blocks are never title material, so they are + * deliberately not in this list. + */ +export const TITLE_NOISE_TAGS: readonly string[] = [ + MARKER_TAGS.useSkills, + MARKER_TAGS.handoffFrom, + MARKER_TAGS.scheduledTask, + MARKER_TAGS.modelSwitchFrom, +]; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ebad612..d62e55f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -187,6 +187,19 @@ export class Session { if (capture && this.titleUserText.trim()) this.titleMaterialFrozen = true; } + /** + * Queues a steering message for the running Task: the engine delivers it between turns as + * a standalone `[user_steering]` user message — sent with the next request input alongside + * that turn's tool outputs, or alone as the continuation input when the turn produced no + * tool calls — so the model sees it without the loop being interrupted. Returns false when + * no Task is running — the host should then submit the text as a normal task instead. + * Delivery is independent of approval mode; anything still queued when the run exits + * (abort included) is discarded. + */ + steer(text: string): boolean { + return this.engine.steer(text); + } + /** * User-initiated request to compact context (e.g. a CLI command): reuses the automatic * compaction flow but skips the threshold check (reason=manual). Only callable at Task diff --git a/packages/core/src/state/agent-state.ts b/packages/core/src/state/agent-state.ts index 98e7bfd..0a74f5b 100644 --- a/packages/core/src/state/agent-state.ts +++ b/packages/core/src/state/agent-state.ts @@ -387,7 +387,7 @@ export function skillMetadataSection(skills: SkillMetadata[]): string { * Renders the complete runtime system Prompt: substitutes `AGENTS.md`, vault key names, Skill * metadata, and the concrete Session runtime environment placeholders into the system Prompt * template. The assembly layer only does placeholder substitution and adds no extra text — - * wrapper text such as `` and the # Vault / # Skills statements are + * wrapper text such as `[developer_instructions]` and the # Vault / # Skills statements are * written directly into the system Prompt template itself (the Prompt is fully * transparent and editable via `system_config.yaml`). Other files in Agent State / Workspace are * never auto-injected. diff --git a/packages/core/src/state/default-config.ts b/packages/core/src/state/default-config.ts index 7e2c48d..c68d1a7 100644 --- a/packages/core/src/state/default-config.ts +++ b/packages/core/src/state/default-config.ts @@ -108,10 +108,11 @@ Communicate with the user precisely and concisely, yet with warmth. Do not repea - When you need information from the internet, browse it with your shell tool — \`curl\` for pages and APIs, or Playwright (if installed) for dynamic sites. # System markers -Some user-side messages are system-synthesized records, not user text to answer directly: -- \`\`: the previous round was interrupted. Inside are the original request, your partial thinking/text, and the tool calls already issued with their results. Continue from where it left off; do not re-run tools whose results are already included. -- \`\`: the previous attempt of this round failed on a transport error (timeout or malformed response) — the user did NOT interrupt — and this request is the automatic retry. Inside are your partial thinking/text and the tool calls already executed with their results. Continue from them; do not re-run tools whose results are already included. -- \`\`: earlier conversation was compacted. This summary replaced the raw transcript and is its only record; treat it as established context and continue the task from it. +Some messages contain system-synthesized blocks written as \`[tag]...[/tag]\`, not user text to answer directly: +- \`[turn_aborted]\`: the previous round was interrupted. Inside are the original request, your partial thinking/text, and the tool calls already issued with their results. Continue from where it left off; do not re-run tools whose results are already included. +- \`[turn_retried]\`: the previous attempt of this round failed on a transport error (timeout or malformed response) — the user did NOT interrupt — and this request is the automatic retry. Inside are your partial thinking/text and the tool calls already executed with their results. Continue from them; do not re-run tools whose results are already included. +- \`[context_summary]\`: earlier conversation was compacted. This summary replaced the raw transcript and is its only record; treat it as established context and continue the task from it. +- \`[user_steering]\`: a user message sent while you were still working, delivered between turns alongside tool results. It is not a new task: incorporate it immediately and adjust course within the current task. # File system - Angle-bracket markers such as \`\`, \`\` and \`\` are not literal paths — substitute the matching values from the Environment section. @@ -129,18 +130,18 @@ These are recommendations, not requirements; adapt them as the task demands. - Delegate self-contained subtasks to other agents with the \`run_subagent\` tool; dispatch independent subtasks in parallel. Start every delegation prompt with your own agent id (e.g. "Caller agent: ") and name the skill the subagent should use when the task matches one. Subagents share your Workspace — exchange data through files. If \`run_subagent\` is not in your tool list, you are the subagent: do the work yourself. - To visit web pages, prefer Playwright when installed; otherwise \`curl\`. When building a web app or frontend, prefer React. - +[developer_instructions] Custom instructions from the developer-editable AGENTS.md. {{AGENTS_MD}} - +[/developer_instructions] # Vault The vault holds this agent's per-agent secrets (agent_state/.vault.toml). Each entry is injected into your shell subprocesses as an environment variable — values never appear in your context. Use the variable names below in commands when a task needs them. {{VAULT_KEYS}} # Skills -Skills are reusable instruction packages stored under /agents//agent_state/skills//SKILL.md. There is no skill tool: when a task matches an installed skill below, or the user asks to use one (a message may start with a block listing skill names), first read that skill's SKILL.md in full with the read_file tool, then follow it. If a request only names a skill without a concrete task, ask the user what they need before starting. +Skills are reusable instruction packages stored under /agents//agent_state/skills//SKILL.md. There is no skill tool: when a task matches an installed skill below, or the user asks to use one (a message may start with a [use_skills] block listing skill names), first read that skill's SKILL.md in full with the read_file tool, then follow it. If a request only names a skill without a concrete task, ask the user what they need before starting. {{SKILL_METADATA}} # Environment @@ -162,7 +163,7 @@ Skills are reusable instruction packages stored under /agents/` tags. This summary will replace the transcript: in the next " + + "`[summary][/summary]` tags. This summary will replace the transcript: in the next " + "context window the raw transcript above will no longer be visible and this summary " + "will be its only record, so include everything needed to continue the task — the " + "original request, current state, next steps, and any learnings. Do not call any " + diff --git a/packages/core/src/trace/resume.ts b/packages/core/src/trace/resume.ts index 087f3ac..d2296c5 100644 --- a/packages/core/src/trace/resume.ts +++ b/packages/core/src/trace/resume.ts @@ -3,7 +3,7 @@ * * Replay produces two results: the **history** injected via setHistory (committed turns only), * and the **carry-over** input resent with the first `run` after resume. Resume is **best-effort**: - * Trace only records real messages, so synthesized carry-over (`` flattening, pairing + * Trace only records real messages, so synthesized carry-over (`[turn_aborted]` flattening, pairing * placeholders) is never written to Trace — replay reconstructs from the original messages * (unanswered input is resent as-is, pairing placeholders are resynthesized as needed). History is * guaranteed to be **structurally valid** (turns complete, tool_call pairs matched), not a @@ -46,7 +46,7 @@ import type { TokenUsagePayload, ToolCallPayload, } from "../omnimessage/index.js"; -import { extractSummary } from "../engine/context-engine.js"; +import { buildContextSummaryText, extractSummary } from "../omnimessage/markers/index.js"; /** Replay result: all the state needed to resume a Session. */ export interface ResumeResult { @@ -60,7 +60,7 @@ export interface ResumeResult { carryOver: OmniMessage[]; /** Compaction closure (file-level): this file's context is fully closed; resume starts a new, empty context. */ contextClosed: boolean; - /** Compaction closure in summarize mode: the reconstructed `` summary, prepended to the next run's input. */ + /** Compaction closure in summarize mode: the reconstructed `[context_summary]` summary, prepended to the next run's input. */ pendingSummary?: OmniMessage; /** Session-level cumulative Token carry-over (the session value from the last token_usage). */ sessionTokens: TokenCounts; @@ -228,11 +228,10 @@ export function resumeTrace(messages: OmniMessage[]): ResumeResult { }; if (p.mode === "summarize") { // Reconstruct the summary from the compaction request's output (the assistant text of - // the last completed Request). + // the last completed Request). Always rebuilt in the current [context_summary] form — + // extractSummary itself still accepts the old tags an old Trace may contain. const summaryText = lastCompletedRequestText(messages); - result.pendingSummary = userText( - `\n${extractSummary(summaryText)}\n`, - ); + result.pendingSummary = userText(buildContextSummaryText(extractSummary(summaryText))); } return result; } diff --git a/packages/core/test/compaction.test.ts b/packages/core/test/compaction.test.ts index 0d5d86f..8d3f236 100644 --- a/packages/core/test/compaction.test.ts +++ b/packages/core/test/compaction.test.ts @@ -6,7 +6,7 @@ * request emits token_usage, both mid-task and at the wrap-up round (reaching the threshold at * task end triggers compaction immediately, without waiting for the next task). * - summarize: appends a compaction prompt to the old LLM (merging in all of this round's tool - * results first if mid-task); the summary is wrapped as a `` user text and fed + * results first if mid-task); the summary is wrapped as a `[context_summary]` user text and fed * as the first input to the new LLM instance; on failure the original context is kept, never downgraded to discard. * - discard: deferred until task end if mid-task; sends no compaction request, just swaps in a new LLM instance directly. * - Process visibility: the compaction request's streamed output is never surfaced to the human, @@ -162,7 +162,7 @@ describe("context compaction", () => { { messages: [assistantText("answer one"), usage(150, 150)] }, // Compaction request: summary + usage (counted into the session cumulative total). { - messages: [assistantText("the distilled summary"), usage(160, 310)], + messages: [assistantText("[summary]the distilled summary[/summary]"), usage(160, 310)], }, ], "llm1", @@ -223,7 +223,7 @@ describe("context compaction", () => { expect(llm1.calls).toHaveLength(2); expect(llm2.calls).toHaveLength(1); const firstInput = llm2.calls[0]!.map(textOf); - expect(firstInput[0]).toBe("\nthe distilled summary\n"); + expect(firstInput[0]).toBe("[context_summary]\nthe distilled summary\n[/context_summary]"); expect(firstInput[1]).toBe("task two"); // Trace splits into files: the old file contains the compaction dialogue and paired events; the new file starts with session_meta. @@ -238,7 +238,7 @@ describe("context compaction", () => { expect(newTrace[0]!.type).toBe("session_meta"); expect( newTrace.some((m) => - ((m.payload as { text?: string }).text ?? "").startsWith(""), + ((m.payload as { text?: string }).text ?? "").startsWith("[context_summary]"), ), ).toBe(true); }); @@ -251,7 +251,7 @@ describe("context compaction", () => { messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }), usage(150, 150)], }, // Compaction request (should include c1's tool_call_output plus the compaction prompt). - { messages: [assistantText("continue: finish step 2")] }, + { messages: [assistantText("[summary]continue: finish step 2[/summary]")] }, ], "llm1", ); @@ -279,7 +279,7 @@ describe("context compaction", () => { // The summary itself is the new instance's first input (no hardcoded continuation instruction appended); the task is finished by the new context. expect(llm2.calls).toHaveLength(1); expect(llm2.calls[0]!.map(textOf)).toEqual([ - "\ncontinue: finish step 2\n", + "[context_summary]\ncontinue: finish step 2\n[/context_summary]", ]); const finalTexts = out .filter((m) => (m.payload as { type?: string }).type === "text") @@ -302,7 +302,7 @@ describe("context compaction", () => { // Original context is kept: the task continues, tool outputs feed back into the old instance as usual (context usage keeps growing). { messages: [assistantText("finished on old context"), usage(190, 340)] }, // Second trigger (context still over the limit) -> retries compaction at the boundary, this time succeeding. - { messages: [assistantText("second try")] }, + { messages: [assistantText("[summary]second try[/summary]")] }, ], "llm1", ); @@ -350,7 +350,7 @@ describe("context compaction", () => { const llm1 = new ScriptedLLM( [ { messages: [assistantText("answer"), usage(150, 150)] }, - { messages: [assistantText("s")] }, + { messages: [assistantText("[summary]s[/summary]")] }, ], "llm1", ); @@ -380,7 +380,7 @@ describe("context compaction", () => { const newTrace = await readTrace(trace.currentPath()); expect(newTrace[0]!.type).toBe("session_meta"); expect( - ((newTrace[1]!.payload as { text?: string }).text ?? "").startsWith(""), + ((newTrace[1]!.payload as { text?: string }).text ?? "").startsWith("[context_summary]"), ).toBe(true); expect((newTrace[2]!.payload as { text?: string }).text).toBe("task 2"); }); @@ -421,7 +421,7 @@ describe("context compaction", () => { }, { messages: [assistantText("t1 done"), usage(10, 20)] }, // Compaction request (sent out immediately when task 1 ends). - { messages: [assistantText("s")] }, + { messages: [assistantText("[summary]s[/summary]")] }, ], "llm1", ); @@ -445,7 +445,7 @@ describe("context compaction", () => { expect(compactionEvents(out2)).toHaveLength(0); expect(llm2.calls).toHaveLength(1); expect(llm2.calls[0]!.map(textOf)).toEqual([ - "\ns\n", + "[context_summary]\ns\n[/context_summary]", "task 2", ]); }); @@ -455,7 +455,7 @@ describe("context compaction", () => { [ // Wrap-up round context usage 100 == threshold 100 -> triggers. { messages: [assistantText("answer"), usage(100, 100)] }, - { messages: [assistantText("eq")] }, + { messages: [assistantText("[summary]eq[/summary]")] }, ], "llm1", ); @@ -511,7 +511,7 @@ describe("context compaction", () => { ]); expect(llm1.calls).toHaveLength(2); - // The next round's input is used as-is as the new instance's first input (no ). + // The next round's input is used as-is as the new instance's first input (no [context_summary]). await collect(engine.run([userText("next task")], { approve: allowAll })); expect(llm2.calls[0]!.map(textOf)).toEqual(["next task"]); @@ -521,7 +521,7 @@ describe("context compaction", () => { expect(newTrace[0]!.type).toBe("session_meta"); }); - it("lenient extraction: output without tags is used verbatim", async () => { + it("lenient extraction: output without [summary] tags is used verbatim", async () => { const llm1 = new ScriptedLLM( [ { messages: [assistantText("answer"), usage(150, 150)] }, @@ -540,7 +540,7 @@ describe("context compaction", () => { await collect(engine.run([userText("go")], { approve: allowAll })); await collect(engine.run([userText("next")], { approve: allowAll })); expect(textOf(llm2.calls[0]![0]!)).toBe( - "\nplain summary text without tags\n", + "[context_summary]\nplain summary text without tags\n[/context_summary]", ); }); @@ -550,7 +550,7 @@ describe("context compaction", () => { // One ordinary task (well under the limit). { messages: [assistantText("small"), usage(10, 10)] }, // Manual compaction request. - { messages: [assistantText("manual s")] }, + { messages: [assistantText("[summary]manual s[/summary]")] }, ], "llm1", ); @@ -570,7 +570,7 @@ describe("context compaction", () => { await collect(engine.run([userText("next")], { approve: allowAll })); expect(llm2.calls[0]!.map(textOf)).toEqual([ - "\nmanual s\n", + "[context_summary]\nmanual s\n[/context_summary]", "next", ]); }); @@ -607,7 +607,7 @@ describe("context compaction", () => { [ // Usage is kept under maxContextLength (100 per settings()) so automatic compaction doesn't jump in first and reset sessionTurns. { messages: [assistantText("hi"), usage(10, 10)] }, - { messages: [assistantText("s")] }, // manual compaction request + { messages: [assistantText("[summary]s[/summary]")] }, // manual compaction request ], "llm1", ); diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index cd902da..0123f4c 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -95,9 +95,9 @@ function execCommandToolConfig() { const isToolCall = (m: OmniMessage): boolean => isCompleteModelMessage(m) && m.payload.type === "tool_call"; -/** Count of text messages in the list starting with `` (flatten carry-over count). */ +/** Count of text messages in the list starting with `[turn_aborted]` (flatten carry-over count). */ const turnAbortedCount = (msgs: OmniMessage[]): number => - msgs.filter((m) => ((m.payload as { text?: string }).text ?? "").startsWith("")) + msgs.filter((m) => ((m.payload as { text?: string }).text ?? "").startsWith("[turn_aborted]")) .length; /** An approval callback that allows everything. */ @@ -453,7 +453,7 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => { const texts = received[0]!.map((m) => (m.payload as { text?: string }).text ?? ""); expect(texts).toContain("go"); expect(texts).toContain("next"); - expect(texts.join("\n")).not.toContain(""); + expect(texts.join("\n")).not.toContain("[turn_aborted]"); }); it("never writes the flatten carry-over to trace (case B): synthesized carry-over is memory-only", async () => { @@ -616,7 +616,7 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => { expect(rows.some((m) => m.origin !== undefined)).toBe(false); }); - it("in-run reconnect never writes the synthesized to trace", async () => { + it("in-run reconnect never writes the synthesized [turn_retried] to trace", async () => { let calls = 0; const inputs: OmniMessage[][] = []; const llm: LLMInterface = { @@ -652,15 +652,15 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => { await collectRun(engine, [userText("go")], allowAll); expect(calls).toBe(2); - // Retry = original input + (carrying the partial text). - expect((inputs[1]![1]!.payload as { text?: string }).text ?? "").toContain(""); - // The synthesized message is only sent to the model: Trace has no / - // ; the original input is written only on its first occurrence. + // Retry = original input + [turn_retried] (carrying the partial text). + expect((inputs[1]![1]!.payload as { text?: string }).text ?? "").toContain("[turn_retried]"); + // The synthesized message is only sent to the model: Trace has no [turn_retried] / + // [turn_aborted]; the original input is written only on its first occurrence. const recorded = await readTrace(trace.currentPath()); expect(turnAbortedCount(recorded)).toBe(0); expect( recorded.some((m) => - ((m.payload as { text?: string }).text ?? "").startsWith(""), + ((m.payload as { text?: string }).text ?? "").startsWith("[turn_retried]"), ), ).toBe(false); expect(recorded.filter((m) => (m.payload as { text?: string }).text === "go")).toHaveLength(1); @@ -1172,12 +1172,12 @@ describe("ContextEngine abort during execution", () => { ); expect(out).toBeDefined(); expect((out!.payload as { tool_call_id?: string }).tool_call_id).toBe("slow"); - // Case A must be a structured backfill and must **not** be flattened into + // Case A must be a structured backfill and must **not** be flattened into [turn_aborted] // (otherwise the already-committed tool_call would lose its pairing). const carriedText = received[1]! .map((m) => (m.payload as { text?: string }).text ?? "") .join(""); - expect(carriedText).not.toContain(""); + expect(carriedText).not.toContain("[turn_aborted]"); }); it("case A backfills outputs for committed-but-undispatched tool_calls (preserves pairing)", async () => { @@ -1258,7 +1258,7 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { await rm(workspace, { recursive: true, force: true }); }); - it("auto-retries on LLM timeout: original input + carrying partial products", async () => { + it("auto-retries on LLM timeout: original input + [turn_retried] carrying partial products", async () => { let calls = 0; const inputs: OmniMessage[][] = []; const llm: LLMInterface = { @@ -1299,14 +1299,14 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { const all = await collectRun(engine, [userText("go")], allowAll); expect(calls).toBe(2); // Initial timeout -> auto-retries once within the same run and succeeds. - // Retry = original input kept as-is + one carrying the partial products - // already produced (not , to avoid the model mistaking it for a user interrupt). + // Retry = original input kept as-is + one [turn_retried] carrying the partial products + // already produced (not [turn_aborted], to avoid the model mistaking it for a user interrupt). expect(inputs[1]).toHaveLength(2); expect(inputs[1]![0]).toEqual(inputs[0]![0]); const retried = (inputs[1]![1]!.payload as { text?: string }).text ?? ""; - expect(retried).toContain(""); - expect(retried).toContain("thinking..."); - expect(retried).not.toContain(""); + expect(retried).toContain("[turn_retried]"); + expect(retried).toContain("[text]thinking...[/text]"); + expect(retried).not.toContain("[turn_aborted]"); // The final reply is produced, with no abort throughout. expect( all.some( @@ -1377,7 +1377,7 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { expect(retryText).toBe("go"); }); - it("auto-retries on LLM malformed: original input + carrying partial products", async () => { + it("auto-retries on LLM malformed: original input + [turn_retried] carrying partial products", async () => { let calls = 0; const inputs: OmniMessage[][] = []; const llm: LLMInterface = { @@ -1419,13 +1419,13 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { expect(calls).toBe(2); // The malformed attempt never entered AgentHub history: the original input is resent, - // plus carrying the partial products already produced. + // plus [turn_retried] carrying the partial products already produced. expect(inputs[1]).toHaveLength(2); expect(inputs[1]![0]).toEqual(inputs[0]![0]); const retried = (inputs[1]![1]!.payload as { text?: string }).text ?? ""; - expect(retried).toContain(""); + expect(retried).toContain("[turn_retried]"); expect(retried).toContain("partial json response"); - expect(retried).not.toContain(""); + expect(retried).not.toContain("[turn_aborted]"); expect( all.some( (m) => isCompleteModelMessage(m) && m.payload.type === "text" && m.payload.text === "done", @@ -1462,17 +1462,17 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { expect(abort).toBeDefined(); expect((abort!.payload as { reason?: string }).reason).toContain("reconnect failed"); - // carry-over = original input + (accumulating partial products from both + // carry-over = original input + [turn_retried] (accumulating partial products from both // failed attempts): the next run resends it merged with the new input, without producing - // . + // [turn_aborted]. await collectRun(engine, [userText("next")], allowAll); const nextRunTexts = inputs[2]!.map((m) => (m.payload as { text?: string }).text ?? ""); expect(nextRunTexts).toHaveLength(3); expect(nextRunTexts[0]).toBe("go"); - expect(nextRunTexts[1]).toContain(""); + expect(nextRunTexts[1]).toContain("[turn_retried]"); expect(nextRunTexts[1]).toContain("partial..."); expect(nextRunTexts[2]).toBe("next"); - expect(nextRunTexts.join("\n")).not.toContain(""); + expect(nextRunTexts.join("\n")).not.toContain("[turn_aborted]"); }); it("surfaces a non-retryable LLM failure (outcome=failed) as a graceful abort (run does not throw)", async () => { @@ -1511,7 +1511,7 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { expect(text).toContain("next"); }); - it("LLM timeout after a tool already executed: retry carries the call/result via (tool runs once)", async () => { + it("LLM timeout after a tool already executed: retry carries the call/result via [turn_retried] (tool runs once)", async () => { let calls = 0; const inputs: OmniMessage[][] = []; const llm: LLMInterface = { @@ -1552,7 +1552,7 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { const all = await collectRun(engine, [userText("go")], allowAll); - // The tool executes exactly once: retry input = original input + (containing + // The tool executes exactly once: retry input = original input + [turn_retried] (containing // a text transcript of the t1 call/result), so the model does not call it again; the // transcript is plain text and is never dispatched again. const content = await readFile(join(workspace, "count.txt"), "utf8").catch(() => ""); @@ -1560,9 +1560,9 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { expect(calls).toBe(2); // Completes after one retry within the same run. expect(inputs[1]![0]).toEqual(inputs[0]![0]); const retried = (inputs[1]![1]!.payload as { text?: string }).text ?? ""; - expect(retried).toContain(""); - expect(retried).toContain(''); - expect(retried).toContain(' { // (both completed and incomplete messages are carried over). await collectRun(engine, [userText("next")], allowAll); const text = inputs[1]!.map((m) => (m.payload as { text?: string }).text ?? "").join("\n"); - expect(text).toContain(""); - expect(text).toContain("half-thought"); - expect(text).toContain("half-text"); + expect(text).toContain("[turn_aborted]"); + expect(text).toContain("[thinking]half-thought[/thinking]"); + expect(text).toContain("[text]half-text[/text]"); expect(text).toContain("go"); expect(text).toContain("next"); }); - it("carry-over after exhausted retries: raw original input + with all attempts' products", async () => { + it("carry-over after exhausted retries: raw original input + [turn_retried] with all attempts' products", async () => { let calls = 0; const inputs: OmniMessage[][] = []; const llm: LLMInterface = { @@ -1665,31 +1665,31 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { await collectRun(engine, [userText("go")], allowAll); expect(calls).toBe(2); - // Retry = original input + (attempt 1's t1 call/result). + // Retry = original input + [turn_retried] (attempt 1's t1 call/result). expect(inputs[1]![0]).toEqual(inputs[0]![0]); - expect((inputs[1]![1]!.payload as { text?: string }).text ?? "").toContain(""); + expect((inputs[1]![1]!.payload as { text?: string }).text ?? "").toContain("[turn_retried]"); - // Next run: carry-over = original input + (accumulating attempt 1's t1 + // Next run: carry-over = original input + [turn_retried] (accumulating attempt 1's t1 // call/result and attempt 2's partial thinking), a single un-nested block; produces no - // . + // [turn_aborted]. await collectRun(engine, [userText("next")], allowAll); const nextRunTexts = inputs[2]!.map((m) => (m.payload as { text?: string }).text ?? ""); expect(nextRunTexts).toHaveLength(3); expect(nextRunTexts[0]).toBe("go"); const block = nextRunTexts[1]!; - expect(block).toContain(''); - expect(block).toContain('retry-thought"); - expect((block.match(//g) ?? []).length).toBe(1); + expect(block).toContain('[tool_call name="exec_command" id="t1"]'); + expect(block).toContain('[tool_call_output id="t1"'); + expect(block).toContain("[thinking]retry-thought[/thinking]"); + expect((block.match(/\[turn_retried\]/g) ?? []).length).toBe(1); expect(nextRunTexts[2]).toBe("next"); - expect(nextRunTexts.join("\n")).not.toContain(""); + expect(nextRunTexts.join("\n")).not.toContain("[turn_aborted]"); // t1 already executed once during the failed attempts (side effect occurred); the // transcript is plain text and is not dispatched again by either the retry or the next run. const content = await readFile(join(workspace, "chain.txt"), "utf8").catch(() => ""); expect(content).toBe("x"); }); - it("user abort after a failed retry: un-nests into the flatten", async () => { + it("user abort after a failed retry: [turn_retried] un-nests into the [turn_aborted] flatten", async () => { const controller = new AbortController(); let calls = 0; const inputs: OmniMessage[][] = []; @@ -1729,17 +1729,17 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { await collectRun(engine, [userText("go")], allowAll, controller.signal); expect(calls).toBe(2); - // The retry input carries . - expect((inputs[1]![1]!.payload as { text?: string }).text ?? "").toContain(""); + // The retry input carries [turn_retried]. + expect((inputs[1]![1]!.payload as { text?: string }).text ?? "").toContain("[turn_retried]"); - // The next run after the interrupt: flattens into a single-level , with - // 's content un-nested and merged in. + // The next run after the interrupt: flattens into a single-level [turn_aborted], with + // [turn_retried]'s content un-nested and merged in. await collectRun(engine, [userText("next")], allowAll); const text = inputs[2]!.map((m) => (m.payload as { text?: string }).text ?? "").join("\n"); - expect(text).toContain(""); - expect(text).toContain("half-1"); - expect(text).not.toContain(""); - expect((text.match(//g) ?? []).length).toBe(1); + expect(text).toContain("[turn_aborted]"); + expect(text).toContain("[thinking]half-1[/thinking]"); + expect(text).not.toContain("[turn_retried]"); + expect((text.match(/\[turn_aborted\]/g) ?? []).length).toBe(1); }); it("keeps raw inputs across repeated pre-request aborts (no flatten)", async () => { @@ -1773,11 +1773,224 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { await collectRun(engine, [userText("next")], allowAll, c2.signal); // Run 3 is normal: its first LLM input = the as-is preserved "go", "next" + "more", - // producing no (input that never made it to a Request is kept as-is + // producing no [turn_aborted] (input that never made it to a Request is kept as-is // per the trailing-input semantics). await collectRun(engine, [userText("more")], allowAll); const texts = received[0]!.map((m) => (m.payload as { text?: string }).text ?? ""); expect(texts).toEqual(["go", "next", "more"]); - expect(texts.join("\n")).not.toContain(""); + expect(texts.join("\n")).not.toContain("[turn_aborted]"); + }); +}); + +describe("ContextEngine mid-run steering ([user_steering])", () => { + let workspace: string; + let traces: string; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), "penguin-ws-")); + traces = await mkdtemp(join(tmpdir(), "penguin-tr-")); + }); + + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + await rm(traces, { recursive: true, force: true }); + }); + + /** Fake environment: streams a delta then closes with a fixed complete output (no real shell). */ + function steeringEnvironment(result = "tool result"): EnvironmentInterface { + return { + listTools: async () => [], + toolPermission: (): ToolPermission => "rw", + async *executeTool({ toolCall: tc }) { + const toolCallId = tc.payload.tool_call_id; + yield partialToolCallOutput({ eventType: "start", toolCallId }); + yield partialToolCallOutput({ eventType: "delta", output: result, toolCallId }); + yield partialToolCallOutput({ eventType: "stop", toolCallId }); + yield toolCallOutput({ output: result, toolCallId }); + }, + }; + } + + /** The text payloads of the [user_steering]-wrapped user messages in a list. */ + const steeringTexts = (msgs: OmniMessage[]): string[] => + msgs + .map((m) => m.payload as { role?: string; text?: string }) + .filter((p) => p.role === "user" && (p.text ?? "").startsWith("[user_steering]")) + .map((p) => p.text!); + + it("delivers queued steering as standalone user messages alongside the turn's tool outputs — traced, streamed, fed to the model in order; tool output untouched", async () => { + const llm = new FakeLLM(); + const trace = new Writer({ tracesDir: traces, sessionId: "sess_steer" }); + const engine = new ContextEngine({ llm, environment: steeringEnvironment(), trace }); + + // Idle: nothing running yet -> steer refuses, the host falls back to a normal task. + expect(engine.steer("too early")).toBe(false); + + // Queue two messages while the task runs (deterministically: from the approval callback, + // i.e. after the tool_call streamed but before the tool executed). + const approve: ApproveFn = async () => { + expect(engine.steer("focus on the tests")).toBe(true); + expect(engine.steer("also update the docs")).toBe(true); + return "allow"; + }; + const all = await collectRun(engine, [userText("go")], approve); + + const expected = [ + "[user_steering]\nfocus on the tests\n[/user_steering]", + "[user_steering]\nalso update the docs\n[/user_steering]", + ]; + + // The tool output itself is never rewritten. + const outputs = all.filter( + (m) => isCompleteModelMessage(m) && m.payload.type === "tool_call_output", + ); + expect(outputs).toHaveLength(1); + expect((outputs[0]!.payload as { output: string }).output).toBe("tool result"); + + // Streamed: the steering user messages are yielded (live consumers never saw this text). + expect(steeringTexts(all)).toEqual(expected); + + // The next turn's LLM input = tool output first, then the steering user messages in order. + const second = llm.receivedSecondInput!; + expect(second.map((m) => (m.payload as { type?: string }).type)).toEqual([ + "tool_call_output", + "text", + "text", + ]); + expect(steeringTexts(second)).toEqual(expected); + + // Trace recorded them as real user input (replay attributes them to the next turn). + const recorded = await readTrace(trace.currentPath()); + expect(steeringTexts(recorded)).toEqual(expected); + const recordedOutputs = recorded.filter( + (m) => (m.payload as { type?: string }).type === "tool_call_output", + ); + expect((recordedOutputs[0]!.payload as { output: string }).output).toBe("tool result"); + + // Task over: the queue window is closed again. + expect(engine.steer("late")).toBe(false); + }); + + it("delivers steering left at loop end as a [user_steering] continuation turn (traced, streamed)", async () => { + // Turn 1 ends with no tool calls while steering is queued mid-stream -> the engine keeps + // looping and sends the queued text as the next input, wrapped in the same marker (UIs + // keep it inside the running Task; the model knows it is mid-task user input). + let engineRef: ContextEngine | null = null; + const inputs: OmniMessage[][] = []; + const llm: LLMInterface = { + async *streamGenerate(params): AsyncGenerator { + inputs.push(params.newMessages); + if (inputs.length === 1) { + yield assistantText("final answer"); + expect(engineRef!.steer("one more thing")).toBe(true); + return { status: "completed" }; + } + yield assistantText("handled the follow-up"); + return { status: "completed" }; + }, + }; + const trace = new Writer({ tracesDir: traces, sessionId: "sess_steer_loop" }); + const engine = new ContextEngine({ llm, environment: steeringEnvironment(), trace }); + engineRef = engine; + + const all = await collectRun(engine, [userText("go")], allowAll); + + const wrapped = "[user_steering]\none more thing\n[/user_steering]"; + // Turn 2 received exactly the wrapped steering message as its input. + expect(inputs).toHaveLength(2); + expect(inputs[1]!.map((m) => (m.payload as { text?: string }).text)).toEqual([wrapped]); + // Streamed and traced like any user input. + expect(steeringTexts(all)).toEqual([wrapped]); + expect(steeringTexts(await readTrace(trace.currentPath()))).toEqual([wrapped]); + }); + + it("steering queued during a mid-run compaction is delivered right after it (never swallowed)", async () => { + // Turn 1 completes over the context threshold -> summarize compaction runs on the old + // LLM; the user steers DURING the compaction request (the acceptance window stays open); + // the new context's first input must be [summary, steering], not just the summary. + let engineRef: ContextEngine | null = null; + const newInputs: OmniMessage[][] = []; + const oldLLM: LLMInterface = { + async *streamGenerate(params): AsyncGenerator { + const texts = params.newMessages.map((m) => (m.payload as { text?: string }).text ?? ""); + if (texts.some((t) => t.includes("summary prompt"))) { + // The compaction request: steering arrives while it streams. + expect(engineRef!.steer("switch to staging")).toBe(true); + yield assistantText("[summary]the gist[/summary]"); + yield tokenUsage(emptyTokenCounts(), { + cache_read: 0, + cache_write: 0, + output: 1, + total: 10, + }); + return { status: "completed" }; + } + // Turn 1: final answer with usage over the threshold. + yield assistantText("done with the answer"); + yield tokenUsage(emptyTokenCounts(), { + cache_read: 0, + cache_write: 0, + output: 5, + total: 5000, + }); + return { status: "completed" }; + }, + }; + const newLLM: LLMInterface = { + async *streamGenerate(params): AsyncGenerator { + newInputs.push(params.newMessages); + yield assistantText("continuing after compaction"); + return { status: "completed" }; + }, + }; + const engine = new ContextEngine({ + llm: oldLLM, + environment: steeringEnvironment(), + createLLM: () => newLLM, + compaction: { + maxContextLength: 1000, + maxSessionTurns: -1, + mode: "summarize", + prompt: "summary prompt", + }, + }); + engineRef = engine; + + const all = await collectRun(engine, [userText("go")], allowAll); + + const wrapped = "[user_steering]\nswitch to staging\n[/user_steering]"; + // The new context received the summary followed by the steering user message. + expect(newInputs).toHaveLength(1); + expect(newInputs[0]!.map((m) => (m.payload as { text?: string }).text)).toEqual([ + "[context_summary]\nthe gist\n[/context_summary]", + wrapped, + ]); + // The steering message reached the output stream too. + expect(steeringTexts(all)).toEqual([wrapped]); + }); + + it("discards the queue on abort — the next run sees no leftover steering", async () => { + const llm = new FakeLLM(); + const engine = new ContextEngine({ llm, environment: steeringEnvironment() }); + const ac = new AbortController(); + const approve: ApproveFn = async () => { + expect(engine.steer("stale steering")).toBe(true); + ac.abort(); + return "allow"; + }; + const first = await collectRun(engine, [userText("go")], approve, ac.signal); + expect(first.some((m) => (m.payload as { type?: string }).type === "abort")).toBe(true); + + // Aborted: whatever was queued is dropped with the run (documented steering contract). + expect(engine.steer("after abort")).toBe(false); + await collectRun(engine, [userText("continue")], allowAll); + const followUpTexts = (llm.receivedSecondInput ?? []) + .map((m) => { + const p = m.payload as { text?: string; output?: string }; + return p.text ?? p.output ?? ""; + }) + .join("\n"); + expect(followUpTexts).not.toContain("stale steering"); + expect(followUpTexts).not.toContain("[user_steering]"); }); }); diff --git a/packages/core/test/markers.test.ts b/packages/core/test/markers.test.ts new file mode 100644 index 0000000..12a30f6 --- /dev/null +++ b/packages/core/test/markers.test.ts @@ -0,0 +1,238 @@ +/** + * Message-marker module tests (packages/core/src/omnimessage/markers): the canonical + * `[tag]…[/tag]` producers, their inverse parsers, and the compatibility rule that every + * parser also accepts the legacy `…` form (markers persist in Traces and in each + * agent's stored compaction prompt) while producers only ever emit the square form. + * + * Behavior covered here used to live next to each call site — engine (`extractSummary`), + * omnimessage (`userSteeringText`), web (`skill-use` / `agent-mentions`) — and moved with the + * consolidation; the host-side tests keep covering the rendering/wiring around them. + */ +import { describe, expect, it } from "vitest"; +import { + MARKER_TAGS, + TITLE_NOISE_TAGS, + buildContextSummaryText, + buildHandoffMessage, + buildModelSwitchMessage, + buildScheduledMessage, + buildSkillsMessage, + buildTurnAbortedBlock, + buildTurnRetriedBlock, + dualFormPatterns, + extractSummary, + markerBlock, + matchDualForm, + parseHandoffMessage, + parseModelSwitchMessage, + parseScheduledMessage, + parseSkillsMessage, + parseUserSteeringText, + startsWithMarker, + stripMarkerBlocks, + transcribeToolCall, + transcribeUserInput, + unwrapSyntheticBlock, + userSteeringText, +} from "../src/omnimessage/markers/index.js"; + +describe("marker block primitives", () => { + it("builds the canonical paired form and matches both forms, square first", () => { + expect(markerBlock("demo", "body")).toBe("[demo]\nbody\n[/demo]"); + const patterns = dualFormPatterns("demo", "([\\s\\S]*?)"); + expect(matchDualForm(patterns, "[demo]a[/demo]")?.[1]).toBe("a"); + expect(matchDualForm(patterns, "b")?.[1]).toBe("b"); + // A message carrying both resolves to the current form. + expect(matchDualForm(patterns, "old [demo]new[/demo]")?.[1]).toBe("new"); + expect(matchDualForm(patterns, "no markers here")).toBeNull(); + }); + + it("stripMarkerBlocks removes both forms plus stray tags, leaving ordinary text alone", () => { + expect(stripMarkerBlocks("a[demo]x[/demo]b", "demo")).toBe("ab"); + expect(stripMarkerBlocks("axb", "demo")).toBe("ab"); + expect(stripMarkerBlocks("a[/demo]b[demo]c", "demo")).toBe("abc"); + expect(stripMarkerBlocks("check the [config] section", "demo")).toBe( + "check the [config] section", + ); + }); + + it("startsWithMarker detects a leading marker in either form only at the start", () => { + expect(startsWithMarker("[context_summary]\nx", MARKER_TAGS.contextSummary)).toBe(true); + expect(startsWithMarker("\nx", MARKER_TAGS.contextSummary)).toBe(true); + expect(startsWithMarker("hi [context_summary]", MARKER_TAGS.contextSummary)).toBe(false); + }); + + it("the title-noise list is the message-prefix markers only (engine blocks are never title material)", () => { + expect(TITLE_NOISE_TAGS).toEqual([ + MARKER_TAGS.useSkills, + MARKER_TAGS.handoffFrom, + MARKER_TAGS.scheduledTask, + MARKER_TAGS.modelSwitchFrom, + ]); + }); +}); + +describe("engine blocks ([turn_aborted] / [turn_retried] / [context_summary] / [summary])", () => { + it("wraps a compaction summary as the new context's first input", () => { + expect(buildContextSummaryText("the gist")).toBe( + "[context_summary]\nthe gist\n[/context_summary]", + ); + }); + + it("extractSummary accepts the current [summary] form, the legacy form, and tagless output", () => { + // The legacy angle form must stay accepted indefinitely: compaction.prompt is persisted in + // existing agents' system_config.yaml, so old agents keep instructing tags. + expect(extractSummary("preamble [summary]new form[/summary] postscript")).toBe("new form"); + expect(extractSummary("preamble old form postscript")).toBe("old form"); + expect(extractSummary(" tagless output used verbatim ")).toBe("tagless output used verbatim"); + // The current form wins when both appear (a model echoing the instruction). + expect(extractSummary("[summary]a[/summary] b")).toBe("a"); + }); + + it("turn blocks round-trip through unwrapSyntheticBlock, both forms, single-level", () => { + const lines = [transcribeUserInput("go"), transcribeToolCall("read_file", "t1", "{}")]; + const aborted = buildTurnAbortedBlock(lines); + expect(aborted.startsWith("[turn_aborted]\n")).toBe(true); + expect(aborted).toContain(' [tool_call name="read_file" id="t1"]{}[/tool_call]'); + expect(unwrapSyntheticBlock(aborted)).toBe(lines.join("\n")); + expect(unwrapSyntheticBlock(buildTurnRetriedBlock([" [text]x[/text]"]))).toBe( + " [text]x[/text]", + ); + // Legacy form (a Trace written before the format change) still unwraps. + expect(unwrapSyntheticBlock("\n x\n")).toBe( + " x", + ); + // Mismatched tags and ordinary text are not blocks. + expect(unwrapSyntheticBlock("[turn_aborted]\nx\n[/turn_retried]")).toBeNull(); + expect(unwrapSyntheticBlock("just a message")).toBeNull(); + }); +}); + +describe("[user_steering] messages", () => { + it("round-trips: a wrapped steering message parses back to the inner text (multiline kept)", () => { + expect(userSteeringText("switch branch")).toBe( + "[user_steering]\nswitch branch\n[/user_steering]", + ); + expect(parseUserSteeringText(userSteeringText("switch branch"))).toBe("switch branch"); + expect(parseUserSteeringText(userSteeringText("line1\nline2"))).toBe("line1\nline2"); + }); + + it("normal user text is left alone (including text merely mentioning the marker)", () => { + expect(parseUserSteeringText("fix the bug")).toBeNull(); + expect(parseUserSteeringText("what does [user_steering] mean?")).toBeNull(); + // A block not spanning the whole message is not a steering message. + expect(parseUserSteeringText("hi\n[user_steering]\nx\n[/user_steering]")).toBeNull(); + expect(parseUserSteeringText("")).toBeNull(); + }); + + it("tolerates trailing whitespace after the closing tag", () => { + expect(parseUserSteeringText("[user_steering]\nok\n[/user_steering]\n")).toBe("ok"); + }); +}); + +describe("origin blocks ([use_skills] / [handoff_from] / [scheduled_task] / [model_switch_from])", () => { + it("[use_skills]: builds a leading block + body and parses it back; an empty list adds nothing", () => { + expect(buildSkillsMessage(["penguin-sdk"], "write me a demo")).toBe( + "[use_skills]\nskills: penguin-sdk\n[/use_skills]\n\nwrite me a demo", + ); + expect(buildSkillsMessage(["a", "b"], "x")).toBe( + "[use_skills]\nskills: a, b\n[/use_skills]\n\nx", + ); + expect(buildSkillsMessage(["solo"], "")).toBe("[use_skills]\nskills: solo\n[/use_skills]"); + expect(buildSkillsMessage([], "hello")).toBe("hello"); + expect(parseSkillsMessage(buildSkillsMessage(["a", "b"], "use them\nline2"))).toEqual({ + skills: ["a", "b"], + rest: "use them\nline2", + }); + // Only a block at the very start counts; an empty skills list is not a block. + expect(parseSkillsMessage(`hi\n${buildSkillsMessage(["a"], "b")}`)).toBeNull(); + expect(parseSkillsMessage("[use_skills]\nskills: , ,\n[/use_skills]")).toBeNull(); + expect(parseSkillsMessage("plain text mentioning [use_skills] only")).toBeNull(); + // Legacy form (old Traces re-rendered). + expect( + parseSkillsMessage("\nskills: solo, duo\n\n\nold body"), + ).toEqual({ + skills: ["solo", "duo"], + rest: "old body", + }); + }); + + it("[handoff_from]: round-trips the origin; only a whole-message block parses", () => { + const origin = { + agentId: "default_agent", + agentName: "General Agent", + sessionId: "session-01ABC", + sessionTitle: "Fix the parser", + workspace: "/data/ws", + }; + const text = buildHandoffMessage(origin); + expect(text.startsWith("[handoff_from]\n")).toBe(true); + expect(parseHandoffMessage(text)).toEqual(origin); + // The parenthetical is omitted when the display name equals the id. + expect(buildHandoffMessage({ agentId: "researcher", agentName: "researcher" })).toContain( + "agent: researcher\n", + ); + expect(parseHandoffMessage(`before\n${text}`)).toBeNull(); + expect(parseHandoffMessage("hello")).toBeNull(); + // Legacy form. + expect( + parseHandoffMessage( + "\nprose line\nagent: default_agent (General Agent)\nworkspace: /data/ws\n", + ), + ).toEqual({ agentId: "default_agent", agentName: "General Agent", workspace: "/data/ws" }); + }); + + it("[scheduled_task]: block + prompt body, parsed back with the body as rest", () => { + const text = buildScheduledMessage("daily_report", "2026-07-16T01:00:00.000Z", "Write it\nnow"); + expect(text.startsWith("[scheduled_task]\n")).toBe(true); + expect(parseScheduledMessage(text)).toEqual({ + origin: { name: "daily_report", firedAt: "2026-07-16T01:00:00.000Z" }, + rest: "Write it\nnow", + }); + // A block without a task name isn't an origin block; mid-text blocks don't parse. + expect( + parseScheduledMessage( + "[scheduled_task]\nfired_at: 2026-01-01T00:00:00Z\n[/scheduled_task]\n\np", + ), + ).toBeNull(); + expect(parseScheduledMessage(`preamble\n${text}`)).toBeNull(); + // Legacy form. + expect( + parseScheduledMessage( + "\nschedule: daily\nfired_at: 2026-01-01T00:00:00Z\n\n\nbody", + ), + ).toEqual({ origin: { name: "daily", firedAt: "2026-01-01T00:00:00Z" }, rest: "body" }); + }); + + it("[model_switch_from]: round-trips the origin, including a parenthesized session title", () => { + const origin = { + sessionId: "session-2026-07-24-10-00-00-abcdef01", + sessionTitle: "Fix (the) parser", + tracePath: "/data/p/agents/a/traces/2026-07-24/session-x_001.jsonl", + workspace: "/data/ws", + prevProvider: "deepseek", + prevModelId: "deepseek-v4-pro", + }; + const text = buildModelSwitchMessage(origin); + expect(text.startsWith("[model_switch_from]\n")).toBe(true); + expect(parseModelSwitchMessage(text)).toEqual(origin); + const minimal = buildModelSwitchMessage({ sessionId: "session-01" }); + expect(minimal).not.toContain("trace:"); + expect(parseModelSwitchMessage(minimal)).toEqual({ sessionId: "session-01" }); + // A block without a session line is not an origin block; partial messages don't parse. + expect( + parseModelSwitchMessage("[model_switch_from]\ntrace: /t.jsonl\n[/model_switch_from]"), + ).toBeNull(); + expect(parseModelSwitchMessage(`${text}\nafter`)).toBeNull(); + // Legacy form. + expect( + parseModelSwitchMessage( + "\nprose\nsession: session-01 (Fix the parser)\ntrace: /data/t.jsonl\n", + ), + ).toEqual({ + sessionId: "session-01", + sessionTitle: "Fix the parser", + tracePath: "/data/t.jsonl", + }); + }); +}); diff --git a/packages/core/test/replay.test.ts b/packages/core/test/replay.test.ts index 544277d..3bdd05f 100644 --- a/packages/core/test/replay.test.ts +++ b/packages/core/test/replay.test.ts @@ -5,7 +5,7 @@ * a start but no stop) are dropped entirely, keeping only outputs paired with already-committed * tool_calls; trailing input is kept as-is as carry-over. * - Pairing fallback: committed tool_calls with no paired output get an interrupted-state placeholder. - * - Compaction wrap-up (file level): summarize rebuilds , discard leaves no + * - Compaction wrap-up (file level): summarize rebuilds [context_summary], discard leaves no * pending input; failed compaction rounds are dropped by the generic rule. * - Tolerates a truncated trailing line left by an abnormal process exit. * - Round-trip: a Trace written out by the engine, once replayed, matches the history the model actually received. @@ -184,7 +184,7 @@ describe("resumeTrace", () => { compactionBegin({ reason: "context", mode: "summarize", context: 10, turns: 1 }), userText("please summarize"), requestBegin(), - assistantText("the gist"), + assistantText("[summary]the gist[/summary]"), requestEnd("completed"), tokenUsage(usage(20), usage(20)), compactionEnd({ reason: "context", mode: "summarize", status: "completed" }), @@ -193,11 +193,92 @@ describe("resumeTrace", () => { expect(result.history).toEqual([]); expect(result.renderMessages).toEqual([]); const summary = result.pendingSummary!.payload as { text: string }; - expect(summary.text).toBe("\nthe gist\n"); + expect(summary.text).toBe("[context_summary]\nthe gist\n[/context_summary]"); expect(result.sessionTurns).toBe(0); expect(result.sessionTokens.total).toBe(20); // Token carry-over includes compaction consumption }); + it("closed context (summarize): the legacy form in an old Trace still rebuilds the summary", () => { + // Dual-form regression: compaction.prompt is persisted in old agents' system_config.yaml, + // so old Traces contain angle-bracket compaction output; extractSummary must keep + // accepting it (the rebuilt pending input always uses the current square form). + const result = resumeTrace([ + meta(), + userText("hello"), + requestBegin(), + assistantText("hi"), + requestEnd("completed"), + tokenUsage(usage(10), usage(10)), + compactionBegin({ reason: "context", mode: "summarize", context: 10, turns: 1 }), + userText("please summarize"), + requestBegin(), + assistantText("the old-form gist"), + requestEnd("completed"), + tokenUsage(usage(20), usage(20)), + compactionEnd({ reason: "context", mode: "summarize", status: "completed" }), + ]); + expect(result.contextClosed).toBe(true); + const summary = result.pendingSummary!.payload as { text: string }; + expect(summary.text).toBe("[context_summary]\nthe old-form gist\n[/context_summary]"); + }); + + it("replays a trace containing [user_steering] user messages as ordinary turn input", () => { + // Mid-run steering is delivered as standalone [user_steering] user messages written to + // Trace alongside that turn's tool outputs: replay attributes them positionally like any + // user input — committed spans keep them in history verbatim (marker and all). + const steered = "[user_steering]\nswitch to the staging config\n[/user_steering]"; + const result = resumeTrace([ + meta(), + userText("run it"), + requestBegin(), + toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }), + requestEnd("completed"), + tokenUsage(usage(10), usage(10)), + toolCallOutput({ output: "result-1", toolCallId: "tc1" }), + // Steering delivered with the tool output as the second turn's input. + userText(steered), + requestBegin(), + assistantText("done"), + requestEnd("completed"), + tokenUsage(usage(20), usage(20)), + // Loop-end steering continuation: a [user_steering] user turn of its own. + userText("[user_steering]\none more thing\n[/user_steering]"), + requestBegin(), + assistantText("handled"), + requestEnd("completed"), + tokenUsage(usage(30), usage(30)), + ]); + expect(textsOf(result.history)).toEqual([ + "run it", + "exec_command", + "result-1", + steered, + "done", + "[user_steering]\none more thing\n[/user_steering]", + "handled", + ]); + expect(result.sessionTurns).toBe(3); + expect(result.carryOver).toEqual([]); + }); + + it("keeps trailing steering user messages as carry-over verbatim (unanswered input is resent)", () => { + // The steering message was persisted but its answering request never committed: it is + // resent as-is on resume, marker and all, next to the tool output it rode along with. + const steered = "[user_steering]\nalso check CI\n[/user_steering]"; + const result = resumeTrace([ + meta(), + userText("run it"), + requestBegin(), + toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }), + requestEnd("completed"), + tokenUsage(usage(10), usage(10)), + toolCallOutput({ output: "late", toolCallId: "tc1" }), + userText(steered), + ]); + expect(textsOf(result.history)).toEqual(["run it", "exec_command"]); + expect(textsOf(result.carryOver)).toEqual(["late", steered]); + }); + it("closed context (discard): empty history and no pending summary", () => { const result = resumeTrace([ meta(), @@ -359,7 +440,7 @@ describe("engine trace round-trip", () => { const recorded = await readTrace(trace.currentPath()); expect( recorded.some((m) => - ((m.payload as { text?: string }).text ?? "").includes(""), + ((m.payload as { text?: string }).text ?? "").includes("[turn_aborted]"), ), ).toBe(false); const result = resumeTrace(recorded); @@ -462,7 +543,7 @@ describe("resumeTrace regressions (PR #39 review)", () => { ]); expect(result.contextClosed).toBe(true); const summary = result.pendingSummary!.payload as { text: string }; - expect(summary.text).toBe("\n\n"); + expect(summary.text).toBe("[context_summary]\n\n[/context_summary]"); expect(summary.text).not.toContain("42"); }); }); diff --git a/packages/core/test/resume.test.ts b/packages/core/test/resume.test.ts index 69dbf1b..54ec2e9 100644 --- a/packages/core/test/resume.test.ts +++ b/packages/core/test/resume.test.ts @@ -247,7 +247,7 @@ describe("agent.resumeSession", () => { SID, [ metaFor(SID, workspace), - userText("gist"), + userText("[context_summary]gist[/context_summary]"), requestBegin(), assistantText("resumed context"), requestEnd("completed"), @@ -260,7 +260,7 @@ describe("agent.resumeSession", () => { const texts = (session.resumedHistory ?? []).map( (m) => (m.payload as { text?: string }).text ?? "", ); - expect(texts).toEqual(["gist", "resumed context"]); + expect(texts).toEqual(["[context_summary]gist[/context_summary]", "resumed context"]); }); it("errors when the session does not exist", async () => { diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 2632523..415c509 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -119,8 +119,8 @@ describe("session-title", () => { expect(sanitizeTitle("『标题』!")).toBe("标题"); expect(sanitizeTitle(" \n ")).toBeNull(); expect(sanitizeTitle("x".repeat(50))).toHaveLength(30); - // A leaked block is stripped from the model output. - expect(sanitizeTitle("\nskills: web-design\n\n构建落地页")).toBe( + // A leaked [use_skills] block is stripped from the model output. + expect(sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\n构建落地页")).toBe( "构建落地页", ); }); @@ -129,20 +129,35 @@ describe("session-title", () => { // The skill-invocation block that wraps a first user message must not reach the title. expect( stripConversationMarkers( - "\nskills: penguin-sdk, web-design\n\n做一个 RAG 应用", + "[use_skills]\nskills: penguin-sdk, web-design\n[/use_skills]\n做一个 RAG 应用", ), ).toBe("做一个 RAG 应用"); - // Handoff and scheduled-task markers are stripped too; ordinary angle-bracket text stays. - expect(stripConversationMarkers("data_analyst继续分析")).toBe( + // Handoff and scheduled-task markers are stripped too; ordinary bracketed text stays. + expect(stripConversationMarkers("[handoff_from]data_analyst[/handoff_from]继续分析")).toBe( "继续分析", ); // The /model switch origin block (the new session's first message) must not leak into the title either. expect( stripConversationMarkers( - "\nsession: session-01\ntrace: /t/x_001.jsonl\n\n继续这个任务", + "[model_switch_from]\nsession: session-01\ntrace: /t/x_001.jsonl\n[/model_switch_from]\n继续这个任务", ), ).toBe("继续这个任务"); expect(stripConversationMarkers("render a
element")).toBe("render a
element"); + expect(stripConversationMarkers("check the [config] section")).toBe( + "check the [config] section", + ); + }); + + it("stripConversationMarkers: the old angle-bracket marker form is still stripped (material from old Traces)", () => { + expect( + stripConversationMarkers("\nskills: web-design\n\n做一个落地页"), + ).toBe("做一个落地页"); + expect(stripConversationMarkers("data_analyst继续分析")).toBe( + "继续分析", + ); + expect( + stripConversationMarkers("session: s1继续这个任务"), + ).toBe("继续这个任务"); }); it("Session.generateTitle: sends via createBareLLM; returns null when no factory is provided", async () => { diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index 3d2854b..d3fa9b5 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -119,15 +119,16 @@ describe("loadOrInitAgentState", () => { expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER); expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER); // AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md - // before Environment; the wrapper text is written directly into + // before Environment; the [developer_instructions] wrapper text is written directly into // the template (the Prompt is transparent about the config). - expect(state.systemConfig.system_prompt).toContain(""); - expect(state.systemConfig.system_prompt).toContain(""); + expect(state.systemConfig.system_prompt).toContain("[developer_instructions]"); + expect(state.systemConfig.system_prompt).toContain("[/developer_instructions]"); // The default template explains the semantics of system-synthesized markers to the model, // and recommends preferring tool use. - expect(state.systemConfig.system_prompt).toContain(""); - expect(state.systemConfig.system_prompt).toContain(""); - expect(state.systemConfig.system_prompt).toContain(""); + expect(state.systemConfig.system_prompt).toContain("[turn_aborted]"); + expect(state.systemConfig.system_prompt).toContain("[turn_retried]"); + expect(state.systemConfig.system_prompt).toContain("[context_summary]"); + expect(state.systemConfig.system_prompt).toContain("[user_steering]"); expect(state.systemConfig.system_prompt).toContain("# Tool use"); // Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file, // which holds API keys) and each Agent's .vault.toml, and states that config can only be @@ -142,7 +143,7 @@ describe("loadOrInitAgentState", () => { state.systemConfig.system_prompt.indexOf("# Environment"), ); // The # Vault and # Skills body sections plus their placeholders: the default template - // places them after and before # Environment, in the order + // places them after [/developer_instructions] and before # Environment, in the order // Vault -> Skills (the statement text is part of the template body, kept even with no // keys/skills). const tpl = state.systemConfig.system_prompt; @@ -150,8 +151,8 @@ describe("loadOrInitAgentState", () => { expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER); expect(tpl).toContain("# Skills"); expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER); - expect(tpl).toContain(""); - expect(tpl.indexOf("")).toBeLessThan(tpl.indexOf("# Vault")); + expect(tpl).toContain("[use_skills]"); + expect(tpl.indexOf("[/developer_instructions]")).toBeLessThan(tpl.indexOf("# Vault")); expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)); expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills")); expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)); @@ -448,10 +449,10 @@ describe("assembleSystemPrompt", () => { // land in the Workspace. expect(prompt).toContain("mention its workspace-relative path in backticks"); expect(prompt).toContain("always place final deliverables in the workspace"); - // The default template wraps AGENTS.md in a XML block. - expect(prompt).toContain(""); - expect(prompt).toContain(""); - expect(prompt.indexOf("")).toBeLessThan( + // The default template wraps AGENTS.md in a [developer_instructions] block. + expect(prompt).toContain("[developer_instructions]"); + expect(prompt).toContain("[/developer_instructions]"); + expect(prompt.indexOf("[developer_instructions]")).toBeLessThan( prompt.indexOf("# Environment"), ); expect(prompt).not.toContain(AGENTS_MD_PLACEHOLDER); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index c729da5..aef753e 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -5,6 +5,9 @@ export default defineConfig({ entry: [ "src/index.ts", "src/omnimessage/index.ts", + // Message markers: their own entry so hosts (web/server/cli) can import the marker + // producers and parsers without pulling the rest of the SDK surface. + "src/omnimessage/markers/index.ts", "src/interfaces.ts", "src/state/model-catalog.ts", ], diff --git a/packages/docs/content/agent-loop.en.md b/packages/docs/content/agent-loop.en.md index 598f990..7401bb1 100644 --- a/packages/docs/content/agent-loop.en.md +++ b/packages/docs/content/agent-loop.en.md @@ -26,7 +26,7 @@ session.run(newMessages, { approve, signal }) │ │ output streams back │ │ └─ LLMOutcome: │ │ timeout / malformed ──► reconnect within the turn │ -│ (≤2, with ; tools not rerun) │ +│ (≤2, with [turn_retried]; tools not rerun) │ │ token_usage + request_end (at LLM-stream end; not waiting │ │ for tools) │ │ │ @@ -81,13 +81,17 @@ The Task ends when a turn produces no `tool_call`. A denial produces a synthetic When `signal` fires, the engine emits an `abort` event and returns immediately, while constructing carry-over content for the next `run`: - **Case A — the model's output had completed** (the turn's `tool_call`s were committed): finished tool results are re-sent as structured `tool_call_output`s; unfinished calls get an `[interrupted: tool aborted by user]` placeholder, keeping `tool_call`/output pairing strictly intact; -- **Case B — the model's output was incomplete**: the whole turn is flattened into one `` user text carrying whatever partial output existed. +- **Case B — the model's output was incomplete**: the whole turn is flattened into one `[turn_aborted]` user text carrying whatever partial output existed. Carry-over enters the model context only — it is never written to the Trace, which records only what actually happened. +## Mid-run steering + +While a Task is running, the host can queue a user message with `session.steer(text)` without interrupting the loop: at the next input assembly the engine delivers it as a **standalone user text message** wrapped in `[user_steering]…[/user_steering]`, sent alongside that turn's tool outputs (or alone as the continuation input when the turn produced no tool calls — the Task keeps going instead of ending). Steering is real user input: written to Trace like any Prompt, yielded to the output stream, and replayed as ordinary turn input on resume; tool outputs are never rewritten. The queue is drained at **every** input assembly — including right after a mid-run compaction, so steering that arrives during the compaction request is delivered, never swallowed. `steer` returns `false` when no Task is running (hosts then submit a normal task); the queue is discarded only when the run exits (abort included). + ## Automatic reconnect -Only LLM-side `timeout` (network timeouts, rate limits, 5xx) and `malformed` (truncated streams, JSON parse failures) trigger an in-run reconnect: the engine re-sends the original input plus a `` block carrying the previous partial output, so tools are never re-executed. Default limit is 2 reconnects with linear backoff (base 250ms); beyond that the turn settles as `failed`. Tool errors are never retried — they are fed back to the model as `tool_call_output` and the model decides what to do next. +Only LLM-side `timeout` (network timeouts, rate limits, 5xx) and `malformed` (truncated streams, JSON parse failures) trigger an in-run reconnect: the engine re-sends the original input plus a `[turn_retried]` block carrying the previous partial output, so tools are never re-executed. Default limit is 2 reconnects with linear backoff (base 250ms); beyond that the turn settles as `failed`. Tool errors are never retried — they are fed back to the model as `tool_call_output` and the model decides what to do next. ## Compaction @@ -110,7 +114,7 @@ Three triggers (`compaction_begin.reason`): | `turns` | Session turn count ≥ `maxSessionTurns` (default -1 = unlimited) | | `manual` | the user runs `/compact` or calls `session.compact()` | -Two modes: `summarize` (default) appends the compaction Prompt to the old context, extracts the ``, wraps it as a `` user text and continues in a **fresh model context**; `discard` simply drops the old context. Compaction rotates the [Trace file](/sessions-and-traces) (`_002`, `_003`, …) — one Trace file always equals one complete model context. `compactability()` probes feasibility before `session.compact()` (`ok | unsupported | empty | just_compacted`). +Two modes: `summarize` (default) appends the compaction Prompt to the old context, extracts the `[summary]`, wraps it as a `[context_summary]` user text and continues in a **fresh model context**; `discard` simply drops the old context. System markers are written as `[tag]…[/tag]`; the earlier angle-bracket form (``, ``, …) is still recognized when reading old Traces and old persisted compaction prompts. Compaction rotates the [Trace file](/sessions-and-traces) (`_002`, `_003`, …) — one Trace file always equals one complete model context. `compactability()` probes feasibility before `session.compact()` (`ok | unsupported | empty | just_compacted`). ## Concurrency model diff --git a/packages/docs/content/agent-loop.zh.md b/packages/docs/content/agent-loop.zh.md index 07fcc12..179237f 100644 --- a/packages/docs/content/agent-loop.zh.md +++ b/packages/docs/content/agent-loop.zh.md @@ -25,7 +25,7 @@ session.run(newMessages, { approve, signal }) │ │ Environment.executeTool ──► 并发执行,输出流式回传 │ │ └─ LLMOutcome: │ │ timeout / malformed ──► 同轮自动重连(≤2 次, │ -│ 附 ,工具不重跑) │ +│ 附 [turn_retried],工具不重跑) │ │ token_usage + request_end(LLM 流结束即产出,不等工具) │ │ │ │ 工具输出按原始调用顺序重排 ──► 作为下一轮输入 │ @@ -78,13 +78,17 @@ Task 由若干连续的 Request(轮)组成,每轮: `signal` 触发中断后,引擎产出 `abort` 事件并立即返回,同时为下一次 `run` 构造补发内容: - **场景 A:模型输出已完成**(该轮 `tool_call` 已提交)——已完成的工具结果按结构化 `tool_call_output` 补发;未执行完的调用补上 `[interrupted: tool aborted by user]` 占位,保证 `tool_call` 与输出严格配对; -- **场景 B:模型输出未完成**——整轮压平为一段 `` 用户文本,携带已产生的部分输出。 +- **场景 B:模型输出未完成**——整轮压平为一段 `[turn_aborted]` 用户文本,携带已产生的部分输出。 补发内容只进入模型上下文,不写入 Trace——Trace 永远只记录真实发生的消息。 +## 运行中插话(Steering) + +Task 运行期间,宿主可通过 `session.steer(text)` 排队一条用户消息而不打断循环:引擎在下一次输入组装时把它作为**独立的用户文本消息**送出,内容包裹在 `[user_steering]…[/user_steering]` 中,与该轮工具输出一起进入下一次请求(该轮没有工具调用时则单独作为继续输入,Task 不会就此结束)。插话是真实的用户输入:像 Prompt 一样写入 Trace、推送到输出流,恢复重放时按普通轮次输入处理;工具输出本身从不被改写。队列在**每次**输入组装时排空——包括运行中压缩完成后的那次,压缩请求期间到达的插话不会被吞掉。没有 Task 运行时 `steer` 返回 `false`(宿主转为发起普通 Task);仅在运行退出(含中断)时丢弃队列。 + ## 自动重连 -只有 LLM 侧的 `timeout`(网络超时、限流、5xx)与 `malformed`(流截断、JSON 解析失败)会触发引擎内自动重连:同一次 `run` 内重发原始输入,并附加 `` 块携带上一次的部分输出,避免工具重复执行。默认最多重连 2 次,线性退避(基数 250ms);超限后该轮以 `failed` 收场。工具错误从不重试——它们作为 `tool_call_output` 反馈给模型,由模型决定下一步。 +只有 LLM 侧的 `timeout`(网络超时、限流、5xx)与 `malformed`(流截断、JSON 解析失败)会触发引擎内自动重连:同一次 `run` 内重发原始输入,并附加 `[turn_retried]` 块携带上一次的部分输出,避免工具重复执行。默认最多重连 2 次,线性退避(基数 250ms);超限后该轮以 `failed` 收场。工具错误从不重试——它们作为 `tool_call_output` 反馈给模型,由模型决定下一步。 ## 上下文压缩(Compaction) @@ -107,7 +111,7 @@ interface CompactionSettings { | `turns` | Session 轮数 ≥ `maxSessionTurns`(默认 -1,即不限) | | `manual` | 用户执行 `/compact` 或调用 `session.compact()` | -两种模式:`summarize`(默认)向旧上下文追加压缩 Prompt,提取 `` 后包装为 `` 用户文本,在**全新的模型上下文**中继续;`discard` 直接丢弃旧上下文。压缩时 [Trace 文件随之轮转](/sessions-and-traces)(`_002`、`_003`……),一个 Trace 文件恒等于一个完整模型上下文。`session.compact()` 前可用 `compactability()` 探询可行性(`ok | unsupported | empty | just_compacted`)。 +两种模式:`summarize`(默认)向旧上下文追加压缩 Prompt,提取 `[summary]` 后包装为 `[context_summary]` 用户文本,在**全新的模型上下文**中继续;`discard` 直接丢弃旧上下文。系统标记统一写作 `[tag]…[/tag]`;读取旧 Trace 与旧压缩 Prompt 时仍识别早期的尖括号形式(``、`` 等)。压缩时 [Trace 文件随之轮转](/sessions-and-traces)(`_002`、`_003`……),一个 Trace 文件恒等于一个完整模型上下文。`session.compact()` 前可用 `compactability()` 探询可行性(`ok | unsupported | empty | just_compacted`)。 ## 并发模型 diff --git a/packages/docs/content/cli.en.md b/packages/docs/content/cli.en.md index 7d98055..55be581 100644 --- a/packages/docs/content/cli.en.md +++ b/packages/docs/content/cli.en.md @@ -42,6 +42,7 @@ In-REPL commands: | Input | Behavior | | --- | --- | +| any text while a Task runs | Mid-run steering: queued and delivered to the model between turns as a `[user_steering]` user message (a `»` acknowledgment echoes the text); rendering is held while you type so streamed output doesn't scribble over the line. If the Task finishes first, the line is sent as the next normal prompt | | `/compact` | Proactively compact the current context | | `/exit`, `/quit` | Quit | diff --git a/packages/docs/content/cli.zh.md b/packages/docs/content/cli.zh.md index 73a3fca..0b4deac 100644 --- a/packages/docs/content/cli.zh.md +++ b/packages/docs/content/cli.zh.md @@ -42,6 +42,7 @@ REPL 内命令: | 输入 | 行为 | | --- | --- | +| 运行中输入任意文字 | 运行中插话:排队后以 `[user_steering]` 用户消息随下一轮送达模型(`»` 确认行会回显文字);输入期间渲染暂挂,流式输出不会打断正在输入的行。若 Task 恰好已结束,该行作为下一条普通消息发送 | | `/compact` | 主动压缩当前上下文 | | `/exit`、`/quit` | 退出 | diff --git a/packages/docs/content/message-flow.en.md b/packages/docs/content/message-flow.en.md index 9162261..25d0e56 100644 --- a/packages/docs/content/message-flow.en.md +++ b/packages/docs/content/message-flow.en.md @@ -102,7 +102,7 @@ Therefore **a renderer must never reconstruct the context from arrival order** | Case | Observable order on the stream | | --- | --- | | User interrupt | (messages produced so far) → the `abort` event — the last message before `run` returns; carry-over goes to the model context only, never streamed, never written to Trace | -| Automatic reconnect | `request_end(timeout \| malformed)` → a fresh `request_begin`; the `` block is model-visible only | +| Automatic reconnect | `request_end(timeout \| malformed)` → a fresh `request_begin`; the `[turn_retried]` block is model-visible only | | Compaction | `compaction_begin` → the compaction request runs against the old context (its streamed output is **not** forwarded, only written to Trace) → that request's `token_usage` → `compaction_end(status)` | | max_turns reached | a length notice → the run ends; unsubmitted input is kept as carry-over | | The Prompt itself | written to Trace, not echoed back onto the stream (the caller already has it) | diff --git a/packages/docs/content/message-flow.zh.md b/packages/docs/content/message-flow.zh.md index 508ecc8..80d954e 100644 --- a/packages/docs/content/message-flow.zh.md +++ b/packages/docs/content/message-flow.zh.md @@ -101,7 +101,7 @@ Human ──run(newMessages)──► engine | 情形 | 流上可见的顺序 | | --- | --- | | 用户中断 | (已产出的消息)→ `abort` 事件——`run` 返回前的最后一条;补发内容只进模型上下文,不上流、不进 Trace | -| 自动重连 | `request_end(timeout \| malformed)` → 新的 `request_begin`;`` 块仅模型可见 | +| 自动重连 | `request_end(timeout \| malformed)` → 新的 `request_begin`;`[turn_retried]` 块仅模型可见 | | 上下文压缩 | `compaction_begin` → 压缩请求在旧上下文中执行(其流式输出**不上行**,只写 Trace)→ 该请求的 `token_usage` → `compaction_end(status)` | | 达到 max_turns | 长度提示消息 → 结束;未提交的输入按补发保留 | | Prompt 本身 | 写入 Trace,但不回流(输入方已有) | diff --git a/packages/docs/content/models.en.md b/packages/docs/content/models.en.md index f7fa7d3..b097fa9 100644 --- a/packages/docs/content/models.en.md +++ b/packages/docs/content/models.en.md @@ -83,7 +83,7 @@ Five levels: `none | low | medium | high | xhigh`, configured per Agent as `mode ## Models decoupled from Agents -An Agent never binds a model: the model is chosen when a Session is created and stays locked for that Session; the same Agent can run different Sessions on different models. The in-session `/model` command changes models handoff-style: it opens a new session for the same Agent on the new model, keeping the current Workspace, whose first message carries a `` source block (the source session id and its Trace file path) — the history is not injected into the new context (some models require thinking payloads and `fidelity` on history replay, which cannot cross models); the model reads the Trace file itself when it needs it, and the source session stays untouched. The three `pricing` buckets feed the usage/cost center's per-Token accounting. +An Agent never binds a model: the model is chosen when a Session is created and stays locked for that Session; the same Agent can run different Sessions on different models. The in-session `/model` command changes models handoff-style: it opens a new session for the same Agent on the new model, keeping the current Workspace, whose first message carries a `[model_switch_from]` source block (the source session id and its Trace file path) — the history is not injected into the new context (some models require thinking payloads and `fidelity` on history replay, which cannot cross models); the model reads the Trace file itself when it needs it, and the source session stays untouched. The three `pricing` buckets feed the usage/cost center's per-Token accounting. Credential handling: diff --git a/packages/docs/content/models.zh.md b/packages/docs/content/models.zh.md index de722f2..94852b1 100644 --- a/packages/docs/content/models.zh.md +++ b/packages/docs/content/models.zh.md @@ -83,7 +83,7 @@ api_key = "sk-..." ## 模型与 Agent 解耦 -Agent 从不绑定模型:模型在创建 Session 时选定,并在该 Session 内锁定不变;同一个 Agent 可以在不同 Session 用不同模型运行。会话内的 `/model` 命令按 handoff 方式换模型:在同一 Agent 下新建一个使用新模型、沿用当前 Workspace 的会话,首条消息携带 `` 源块(源会话 id 与其 Trace 文件路径)——历史不注入新上下文(部分模型回放历史时必须携带 thinking 与 `fidelity`,不能跨模型),模型需要时按路径自行读取;原会话保持不变。`pricing` 三档价格供用量/成本中心按 Token 计费。 +Agent 从不绑定模型:模型在创建 Session 时选定,并在该 Session 内锁定不变;同一个 Agent 可以在不同 Session 用不同模型运行。会话内的 `/model` 命令按 handoff 方式换模型:在同一 Agent 下新建一个使用新模型、沿用当前 Workspace 的会话,首条消息携带 `[model_switch_from]` 源块(源会话 id 与其 Trace 文件路径)——历史不注入新上下文(部分模型回放历史时必须携带 thinking 与 `fidelity`,不能跨模型),模型需要时按路径自行读取;原会话保持不变。`pricing` 三档价格供用量/成本中心按 Token 计费。 凭证处理: diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index 4f90a8a..bbf2a52 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -144,7 +144,8 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode | DELETE | / | Delete the Session (along with its Traces and scratch files) | | GET | /messages | Full OmniMessage history | | GET | /stream | SSE event stream (next section) | -| POST | /tasks | Start a Task: `{input: TaskInputPart[], thinkingLevel?}` → 202 | +| POST | /tasks | Start a Task: `{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202. With `queueIfBusy`, a busy session holds the input as a follow-up (`queued: true`) and auto-starts it as an ordinary next task once idle; `task_state` events report the queued count | +| POST | /steer | Mid-run steering: `{text}` queues a message for the running Task (delivered between turns as a standalone `[user_steering]` user message) → 202; 409 `not_running` when no Task is in progress | | POST | /approvals/:toolCallId | Approval decision: `{decision}` is `allow` or `deny` → 204 | | POST | /abort | Interrupt the current Task: 202 when triggered, 204 when idle | | POST | /compact | Trigger context compaction: 202; 409 `nothing_to_compact` when there is nothing to compact | @@ -208,7 +209,7 @@ interface ApprovalDecisionRequest { } ``` -The Web's `/model` switch has no dedicated endpoint: like the @ handoff, it composes the ordinary APIs above — session creation opens a new Session for the same Agent (the chosen model, the source Workspace carried over), then POST /tasks sends a first message opening with a `` source block (the source session id, its `tracePath`, the Workspace, and the previous model pair); the model reads that Trace file itself when it needs the earlier history. +The Web's `/model` switch has no dedicated endpoint: like the @ handoff, it composes the ordinary APIs above — session creation opens a new Session for the same Agent (the chosen model, the source Workspace carried over), then POST /tasks sends a first message opening with a `[model_switch_from]` source block (the source session id, its `tracePath`, the Workspace, and the previous model pair); the model reads that Trace file itself when it needs the earlier history. ## Streaming (SSE) diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index f1a4c25..9a4738f 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -144,7 +144,8 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与 | DELETE | / | 删除 Session(连同 Trace 与暂存文件) | | GET | /messages | 完整 OmniMessage 历史 | | GET | /stream | SSE 事件流(见下节) | -| POST | /tasks | 发起 Task:`{input: TaskInputPart[], thinkingLevel?}` → 202 | +| POST | /tasks | 发起 Task:`{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202。带 `queueIfBusy` 时,运行中的 Session 会把输入暂存为跟进消息(`queued: true`),空闲后按序自动作为普通 Task 发出;`task_state` 事件携带排队数 | +| POST | /steer | 运行中插话:`{text}` 为运行中的 Task 排队一条消息(作为独立的 `[user_steering]` 用户消息随下一轮送达)→ 202;无 Task 运行返回 409 `not_running` | | POST | /approvals/:toolCallId | 审批决定:`{decision}` 取 `allow` 或 `deny` → 204 | | POST | /abort | 中断当前 Task:已触发返回 202,无任务返回 204 | | POST | /compact | 触发上下文压缩:202;无可压缩内容返回 409 `nothing_to_compact` | @@ -207,7 +208,7 @@ interface ApprovalDecisionRequest { } ``` -Web 的 `/model` 模型切换没有专用接口:它按 @ handoff 的方式复用上面的普通接口——先用会话创建接口在同一 Agent 下新建 Session(选定新模型并沿用源 Workspace),再 POST /tasks 发送以 `` 源块开头的首条消息(源会话 id、其 `tracePath`、Workspace 与原模型二元组),模型需要早前历史时自行读取该 Trace 文件。 +Web 的 `/model` 模型切换没有专用接口:它按 @ handoff 的方式复用上面的普通接口——先用会话创建接口在同一 Agent 下新建 Session(选定新模型并沿用源 Workspace),再 POST /tasks 发送以 `[model_switch_from]` 源块开头的首条消息(源会话 id、其 `tracePath`、Workspace 与原模型二元组),模型需要早前历史时自行读取该 Trace 文件。 ## 流式接口(SSE) diff --git a/packages/docs/content/sessions-and-traces.en.md b/packages/docs/content/sessions-and-traces.en.md index ac82a33..032286c 100644 --- a/packages/docs/content/sessions-and-traces.en.md +++ b/packages/docs/content/sessions-and-traces.en.md @@ -77,11 +77,11 @@ The Trace is the single source of truth for recovery — there is no separate se Recovery requires that the Workspace and the model still exist. What recovery guarantees is structural legality: only committed turns are replayed, with `tool_call` / `tool_call_output` pairing intact; incomplete model output (thinking, text) is allowed to be lost. A truncated last line left by an abnormal process exit is tolerated and ignored. See `packages/core/src/trace/resume.ts`. -Special case: if the latest Trace file ends with a completed compaction, that context is closed as a whole — resume starts from an empty context; in summarize mode the `` is reconstructed and prepended to the first input after resume. +Special case: if the latest Trace file ends with a completed compaction, that context is closed as a whole — resume starts from an empty context; in summarize mode the `[context_summary]` is reconstructed and prepended to the first input after resume (old Traces using the earlier angle-bracket `` form are still understood). ## Model switch (/model) -The Web's `/model` command changes models the way the @ handoff does: it creates a new Session under the same Agent via the ordinary session-creation API (the chosen model, **the source session's Workspace** — so files stay reachable), and the first message opens with a `` source block — the source session id, the absolute path of its latest Trace file, the Workspace, and the previous model pair — followed by whatever the user typed. The history is **not injected** into the new context: some models require thinking payloads and `fidelity` byte-for-byte when history is replayed, which cannot cross models — instead the model reads the source Trace file itself (JSONL, one message envelope per line) when it needs the earlier context. The source session and its Trace are untouched. +The Web's `/model` command changes models the way the @ handoff does: it creates a new Session under the same Agent via the ordinary session-creation API (the chosen model, **the source session's Workspace** — so files stay reachable), and the first message opens with a `[model_switch_from]` source block — the source session id, the absolute path of its latest Trace file, the Workspace, and the previous model pair — followed by whatever the user typed. The history is **not injected** into the new context: some models require thinking payloads and `fidelity` byte-for-byte when history is replayed, which cannot cross models — instead the model reads the source Trace file itself (JSONL, one message envelope per line) when it needs the earlier context. The source session and its Trace are untouched. ## Field fidelity diff --git a/packages/docs/content/sessions-and-traces.zh.md b/packages/docs/content/sessions-and-traces.zh.md index c19538d..60f312c 100644 --- a/packages/docs/content/sessions-and-traces.zh.md +++ b/packages/docs/content/sessions-and-traces.zh.md @@ -77,11 +77,11 @@ Trace 是恢复的唯一事实来源,没有独立的会话数据库需要与 恢复的前提是 Workspace 与模型仍然存在。恢复保证的是结构合法性:只回放已提交的轮次,`tool_call` 与 `tool_call_output` 配对完整;未完成的模型输出(thinking、文本)允许丢失。异常退出留下的截断末行会被容忍并忽略。实现见 `packages/core/src/trace/resume.ts`。 -特殊情形:若最新 Trace 文件以一次完成的压缩收尾,则该上下文已整体关闭——恢复从空上下文开始;summarize 模式下会重建 `` 摘要,前置到恢复后第一轮输入中。 +特殊情形:若最新 Trace 文件以一次完成的压缩收尾,则该上下文已整体关闭——恢复从空上下文开始;summarize 模式下会重建 `[context_summary]` 摘要,前置到恢复后第一轮输入中(旧 Trace 中早期的尖括号 `` 形式仍可识别)。 ## 模型切换(/model) -Web 的 `/model` 命令按 @ handoff 的方式换模型:用普通的会话创建接口在同一 Agent 下新建一个 Session(选定新模型,**沿用源会话的 Workspace**,文件因此保持可达),首条消息以 `` 源块开头——携带源会话 id、其最新 Trace 文件的绝对路径、Workspace 与原模型二元组,用户输入的剩余文字紧随其后。历史**不注入**新上下文:部分模型回放历史时要求 thinking 与 `fidelity` 逐字一致,跨模型注入不可行——模型需要早前上下文时按路径自行读取源 Trace 文件(JSONL,每行一个消息信封)。源会话与其 Trace 不受任何影响。 +Web 的 `/model` 命令按 @ handoff 的方式换模型:用普通的会话创建接口在同一 Agent 下新建一个 Session(选定新模型,**沿用源会话的 Workspace**,文件因此保持可达),首条消息以 `[model_switch_from]` 源块开头——携带源会话 id、其最新 Trace 文件的绝对路径、Workspace 与原模型二元组,用户输入的剩余文字紧随其后。历史**不注入**新上下文:部分模型回放历史时要求 thinking 与 `fidelity` 逐字一致,跨模型注入不可行——模型需要早前上下文时按路径自行读取源 Trace 文件(JSONL,每行一个消息信封)。源会话与其 Trace 不受任何影响。 ## 字段保真 diff --git a/packages/docs/content/skills.en.md b/packages/docs/content/skills.en.md index 9f1a039..40172da 100644 --- a/packages/docs/content/skills.en.md +++ b/packages/docs/content/skills.en.md @@ -38,7 +38,7 @@ Parsing is tolerant: only `key: value` scalar lines inside the first `---` block Skills follow an "index first, body on demand" design: the system prompt injects only each installed Skill's metadata (name + description) through the `{{SKILL_METADATA}}` placeholder, and instructs the model to read the matching `SKILL.md` in full via the shell before following it. There is no dedicated skill tool — reading the body is just one `read_file` or shell call (see [Tools & Approval](/tools)). -Chat can also pin skills explicitly: the message then starts with a `` block listing the skill names. +Chat can also pin skills explicitly: the message then starts with a `[use_skills]` block listing the skill names (the earlier `` form is still recognized when re-rendering old Traces). If a message only names a skill without a concrete task, the model is instructed to ask what is needed before starting. diff --git a/packages/docs/content/skills.zh.md b/packages/docs/content/skills.zh.md index a98ab84..25e5d68 100644 --- a/packages/docs/content/skills.zh.md +++ b/packages/docs/content/skills.zh.md @@ -38,7 +38,7 @@ updated: 2026-07-17 Skill 采用「先索引、后正文」的设计:系统 Prompt 经 `{{SKILL_METADATA}}` 占位符只注入每个已安装 Skill 的元数据(name + description),并指示模型在任务匹配某个 Skill 时,先用 Shell 完整读取对应的 `SKILL.md`,再遵循执行。系统不设专门的 Skill 工具,读取正文就是一次 `read_file` 或 Shell 调用(见 [工具与审批](/tools))。 -对话中也可以显式指定 Skill:此时消息以 `` 块开头,列出要使用的 Skill 名。 +对话中也可以显式指定 Skill:此时消息以 `[use_skills]` 块开头,列出要使用的 Skill 名(重新渲染旧 Trace 时仍识别早期的 `` 形式)。 若消息只点名 Skill 而没有给出具体任务,模型会先询问需求再开始。 diff --git a/packages/docs/content/web-app.en.md b/packages/docs/content/web-app.en.md index 3ebc825..42fe68e 100644 --- a/packages/docs/content/web-app.en.md +++ b/packages/docs/content/web-app.en.md @@ -33,7 +33,7 @@ The interface language (中文 / English / system) and theme (light / dark / sys ### Creating a Conversation -A new conversation starts as a draft: pick the Agent, the Workspace (via a server-side directory browser), the approval mode, the model, and the thinking level before sending the first message. The Session is created on first send, and from then on its model and Workspace are locked. Switching the thinking level or the model in the draft makes the switched-to value the new default: the level is written back to the selected Agent's `model.thinking_level` immediately, and the picked model carries over as the next conversation's default. Inside an active session the thinking level is a per-turn parameter: the composer's picker starts out showing the Agent config's level and auto-follows it until touched (sends omit the level, so config edits keep taking effect); a pick sticks for the session and rides on every subsequent send (never written back to the Agent config); the model stays locked per session — the `/model` command switches models the way the @ handoff does: it opens a new session for the same Agent on the picked model, keeping the current Workspace, whose first message carries a `` source block (source session id, Trace file path, previous model) followed by whatever was left in the composer (an interface-language auto-line when empty). In the new session that block collapses into a "switched model" banner linking back to the source conversation, and the model reads the source Trace file itself when it needs the earlier history. +A new conversation starts as a draft: pick the Agent, the Workspace (via a server-side directory browser), the approval mode, the model, and the thinking level before sending the first message. The Session is created on first send, and from then on its model and Workspace are locked. Switching the thinking level or the model in the draft makes the switched-to value the new default: the level is written back to the selected Agent's `model.thinking_level` immediately, and the picked model carries over as the next conversation's default. Inside an active session the thinking level is a per-turn parameter: the composer's picker starts out showing the Agent config's level and auto-follows it until touched (sends omit the level, so config edits keep taking effect); a pick sticks for the session and rides on every subsequent send (never written back to the Agent config); the model stays locked per session — the `/model` command switches models the way the @ handoff does: it opens a new session for the same Agent on the picked model, keeping the current Workspace, whose first message carries a `[model_switch_from]` source block (source session id, Trace file path, previous model) followed by whatever was left in the composer (an interface-language auto-line when empty). In the new session that block collapses into a "switched model" banner linking back to the source conversation, and the model reads the source Trace file itself when it needs the earlier history. There are four approval modes: `allow-all`, `deny-all`, `read-only` (only read-only tools pass), and `always-ask`. See [Tools and Approvals](/tools). @@ -47,7 +47,8 @@ There are four approval modes: `allow-all`, `deny-all`, `read-only` (only read-o ### Input and Shortcuts - Enter sends, Shift+Enter inserts a newline, and images can be pasted; -- Typing `/` opens the slash menu: trigger context compaction (`/compact`) or toggle installed Skills — chosen Skills are sent along with the message in a `` block; +- Typing `/` opens the slash menu: trigger context compaction (`/compact`) or toggle installed Skills — chosen Skills are sent along with the message in a `[use_skills]` block; +- While a Task is running the input stays live and the toolbar keeps a single action button: an empty composer shows **Stop**, and typing turns it into **Send**, whose behavior follows the **mid-run send mode** from the toolbar's More-settings popover (a compact extensible settings panel, also available in draft state; the choice is remembered): **Steer** (default) delivers the text mid-run as a `[user_steering]` user message with the next turn, **Queue** holds the whole message server-side as a follow-up and auto-sends it as an ordinary new message when the run finishes (an "N queued" hint shows near the input until then; the queue survives page reloads); - Typing `@` mentions another Agent to hand the conversation over to it; - When human approval is required, tool calls show inline allow/deny buttons in the message stream; the approval mode can be changed mid-Session. diff --git a/packages/docs/content/web-app.zh.md b/packages/docs/content/web-app.zh.md index 80fb78b..208af69 100644 --- a/packages/docs/content/web-app.zh.md +++ b/packages/docs/content/web-app.zh.md @@ -33,7 +33,7 @@ penguin web ### 新建会话 -新会话从草稿开始:先选择 Agent、Workspace(服务器端目录浏览器选取)、审批模式、模型与思考等级,再发送第一条消息。Session 在首次发送时才真正创建,此后该会话的模型与 Workspace 即被锁定。草稿里切换思考等级或模型时,切换后的值即成为新的默认:思考等级立即写回所选 Agent 的 `model.thinking_level`,所选模型则作为下一个新会话的默认延续。进行中的会话里,思考等级是逐轮参数:输入区拾取器初始显示 Agent 配置的档位并自动跟随(未选择时发送不携带档位,配置修改持续生效),选定后即固定为该会话档位、随每次发送下发(不写回 Agent 配置);模型仍在会话内锁定,改用 `/model` 命令切换模型——与 @ handoff 同一方式:在同一 Agent 下新建一个使用所选模型、沿用当前 Workspace 的会话并跳转,其首条消息携带 `` 源块(源会话 id、Trace 文件路径、原模型),输入框剩余文字随之发出(为空时发一句界面语言的自动消息);新会话中该源块折叠为一条“已切换模型”横幅,可点击回到原会话,模型需要早前历史时按路径自行读取源 Trace 文件。 +新会话从草稿开始:先选择 Agent、Workspace(服务器端目录浏览器选取)、审批模式、模型与思考等级,再发送第一条消息。Session 在首次发送时才真正创建,此后该会话的模型与 Workspace 即被锁定。草稿里切换思考等级或模型时,切换后的值即成为新的默认:思考等级立即写回所选 Agent 的 `model.thinking_level`,所选模型则作为下一个新会话的默认延续。进行中的会话里,思考等级是逐轮参数:输入区拾取器初始显示 Agent 配置的档位并自动跟随(未选择时发送不携带档位,配置修改持续生效),选定后即固定为该会话档位、随每次发送下发(不写回 Agent 配置);模型仍在会话内锁定,改用 `/model` 命令切换模型——与 @ handoff 同一方式:在同一 Agent 下新建一个使用所选模型、沿用当前 Workspace 的会话并跳转,其首条消息携带 `[model_switch_from]` 源块(源会话 id、Trace 文件路径、原模型),输入框剩余文字随之发出(为空时发一句界面语言的自动消息);新会话中该源块折叠为一条“已切换模型”横幅,可点击回到原会话,模型需要早前历史时按路径自行读取源 Trace 文件。 审批模式共四种:`allow-all`(全部放行)、`deny-all`(全部拒绝)、`read-only`(仅放行只读工具)、`always-ask`(每次询问),详见[工具与审批](/tools)。 @@ -47,7 +47,8 @@ penguin web ### 输入与快捷操作 - Enter 发送,Shift+Enter 换行,支持粘贴图片; -- 输入 `/` 打开快捷菜单:触发上下文压缩(`/compact`),或勾选已安装的 Skill——所选 Skill 会以 `` 块随消息发送; +- 输入 `/` 打开快捷菜单:触发上下文压缩(`/compact`),或勾选已安装的 Skill——所选 Skill 会以 `[use_skills]` 块随消息发送; +- Task 运行期间输入框保持可用,工具条只保留一个操作按钮:输入框为空时是**停止**,一旦输入内容即变为**发送**,其行为遵循工具条「更多设置」弹出分组中的**运行中发送方式**(一个可扩展的设置面板,草稿态同样可设,选择会被记忆):**插话**(默认)把文字以 `[user_steering]` 用户消息随下一轮送达运行中的 Agent;**排队** 把整条消息暂存在服务端,本轮结束后自动作为普通新消息发出(期间在输入框附近显示「N 条已排队」提示;队列存放在服务端,刷新页面不丢失); - 输入 `@` 提及其他 Agent,将会话交接给它; - 需要人工审批时,工具调用在消息流中内联显示“允许 / 拒绝”按钮;审批模式在会话中途可随时调整。 diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 7a49244..d0e5943 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -469,6 +469,8 @@ export interface SessionInfo { status: SessionStatus; /** Number of approvals awaiting human decision (a persisted count outside server events, for list badges). */ pendingApprovalCount: number; + /** Number of queued follow-up tasks (`queueIfBusy`) awaiting auto-start once the session is idle. */ + pendingFollowUpCount: number; /** Whether a Trace record exists (a Task has been started). */ hasTrace: boolean; /** Whether archived (hidden from the default list, grouped under "Archived"). */ @@ -477,7 +479,7 @@ export interface SessionInfo { * Absolute path of the session's latest Trace file (the current context shard); absent * when no Trace exists yet. Populated on the **single-session GET only** — list rows omit * it (locating it costs a directory walk per Session). The web's `/model` switch puts it - * into the new session's `` block so the model can read the source + * into the new session's `[model_switch_from]` block so the model can read the source * history itself when it needs it. */ tracePath?: string; @@ -573,14 +575,34 @@ export interface TaskCreateRequest { /** * Thinking level for this Task's LLM requests (a per-turn parameter; one of * `none | low | medium | high | xhigh`, anything else is a 400). Omitted = falls back to - * the session's default (the Agent config's `model.thinking_level`). + * the session's default (the Agent config's `model.thinking_level`). A queued follow-up + * (`queueIfBusy`) keeps its level and applies it when it auto-starts. */ thinkingLevel?: ThinkingLevelName; + /** + * Queue instead of 409 when a Task/compaction is already in progress: the input is held + * server-side and auto-starts as an ordinary next task once the session returns to idle + * (in queue order, one at a time). The response then carries `queued: true`. + */ + queueIfBusy?: boolean; } export interface TaskCreateResponse { /** Current actual session_id: a Trace-less invalid Session self-heals and returns a new id; the frontend updates its route accordingly. */ sessionId: string; + /** True when `queueIfBusy` enqueued the input as a follow-up instead of starting it (absent/false: the task started). */ + queued?: boolean; +} + +/** + * Mid-run steering (POST /api/sessions/:id/steer): a user message for the **running** Task, + * delivered by core between turns as a standalone `[user_steering]` user message. 202 on + * queue; 409 `not_running` when no Task is in progress (the frontend falls back to a normal + * task POST). + */ +export interface SteerRequest { + /** Non-empty message text (trimmed server-side). */ + text: string; } export interface ApprovalDecisionRequest { @@ -598,8 +620,8 @@ export type ServerEvent = * calls under read-only (see runtime/approvals.ts); pending approvals are resent on reconnect. */ | { type: "approval_request"; toolCall: OmniMessage; origin?: string[] } - /** Session run status flip (for toggling the input area and list). */ - | { type: "task_state"; state: SessionStatus } + /** Session run status flip (for toggling the input area and list); `queued` = queued follow-up count (see TaskCreateRequest.queueIfBusy). */ + | { type: "task_state"; state: SessionStatus; queued?: number } /** The model-generated title after the first turn has been persisted (for in-place list updates). */ | { type: "session_title"; sessionId: string; title: string } /** Last-Event-ID has been evicted from the buffer: the frontend should re-fetch the history endpoint before continuing to consume this connection. */ @@ -707,7 +729,7 @@ export interface TraceTaskStats { /** * This turn's duration span: `startTs` = the moment of this turn's **first `request_begin`** * — duration only looks at LLM requests, not the timestamp of user text like the user - * Prompt / compaction summary (`` is created during compaction but only + * Prompt / compaction summary (`[context_summary]` is created during compaction but only * persisted on the next run; resuming the next day would inflate the first turn by a whole * day for no reason); `endTs` = the moment of the last non-session_meta message in the * range. For a degenerate turn with no Request at all (interrupted right after sending), diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts index 112b6e1..7787ee1 100644 --- a/packages/server/src/http/routes/sessions.ts +++ b/packages/server/src/http/routes/sessions.ts @@ -207,7 +207,7 @@ export function sessionsRoutes(deps: AppDeps): Hono { const info = await deps.sessionService.toInfo(row, hasTrace); // Single-session GET only: the latest Trace file's absolute path (a directory walk per // call — too costly for list rows). The web's /model switch hands it to the new session's - // block so the model can read the source history itself. + // [model_switch_from] block so the model can read the source history itself. const tracePath = hasTrace ? await deps.sessionService.latestTracePath(row) : undefined; return c.json({ session: { ...info, ...(tracePath !== undefined ? { tracePath } : {}) }, @@ -353,7 +353,11 @@ export function sessionsRoutes(deps: AppDeps): Hono { // caused by a stale running/idle in the list; followed by replaying all still-pending // approval requests. const initialEvents: ServerEvent[] = [ - { type: "task_state", state: deps.manager.statusOf(row.sessionId) }, + { + type: "task_state", + state: deps.manager.statusOf(row.sessionId), + queued: deps.manager.pendingFollowUpCount(row.sessionId), + }, ...deps.manager.pendingApprovals(row.sessionId).map((p) => ({ type: "approval_request" as const, toolCall: p.toolCall, @@ -368,13 +372,30 @@ export function sessionsRoutes(deps: AppDeps): Hono { const body = await readJson(c); const input = parseTaskInput(body); // Per-turn thinking level (optional): validated against the five names; omitted follows - // the session's default. + // the session's default. A queued follow-up keeps its level for its auto-start. const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS); + // Follow-up queue: with queueIfBusy, a busy session enqueues the input instead of 409 + // (auto-starts as an ordinary next task once idle; the response says which happened). + const queueIfBusy = body.queueIfBusy === true; // 202: the Task executes on the server, decoupled from the SSE connection; sessionId is the current actual id (the new id after self-heal). - const { sessionId } = await deps.manager.startTask(row.sessionId, input, { + const { sessionId, queued } = await deps.manager.startTask(row.sessionId, input, { ...(thinkingLevel !== undefined ? { thinkingLevel } : {}), + queueIfBusy, }); - return c.json({ sessionId } satisfies TaskCreateResponse, 202); + return c.json({ sessionId, queued } satisfies TaskCreateResponse, 202); + }); + + // Mid-run steering: queue a user message for the running Task; core delivers it between + // turns as a standalone `[user_steering]` user message (the model sees it without the loop + // being interrupted). 409 not_running when no Task is in progress — the frontend then falls + // back to a normal task POST. + app.post("/:sessionId/steer", async (c) => { + const row = resolveSession(c); + const body = await readJson(c); + const text = typeof body.text === "string" ? body.text.trim() : ""; + if (!text) throw badRequest("text must be a non-empty string."); + deps.manager.steer(row.sessionId, text); + return c.body(null, 202); }); app.post("/:sessionId/approvals/:toolCallId", async (c) => { diff --git a/packages/server/src/runtime/scheduler.ts b/packages/server/src/runtime/scheduler.ts index 721304d..0444020 100644 --- a/packages/server/src/runtime/scheduler.ts +++ b/packages/server/src/runtime/scheduler.ts @@ -16,7 +16,7 @@ */ import { createHash } from "node:crypto"; import { readdir } from "node:fs/promises"; -import { agentsDir, userText } from "@prismshadow/penguin-core"; +import { agentsDir, buildScheduledMessage, userText } from "@prismshadow/penguin-core"; import type { ProjectsRepo } from "../db/repos/projects.js"; import type { SchedulesRepo, ScheduleStateRow } from "../db/repos/schedules.js"; import type { SessionsRepo } from "../db/repos/sessions.js"; @@ -51,21 +51,12 @@ export interface ScheduleSessionCreator { } /** - * Trigger input = a `` origin block (task name and fire time) + the prompt body: - * tells the model this was fired by a schedule; the frontend collapses the origin block into a - * one-line schedule hint (Trace shows it verbatim). + * Trigger input = a `[scheduled_task]` origin block (task name and fire time) + the prompt + * body: tells the model this was fired by a schedule; the frontend collapses the origin block + * into a one-line schedule hint (Trace shows it verbatim). The block itself is built by core's + * marker module, which also owns the parser the frontend uses. */ -export function scheduledMessage(name: string, firedAt: string, prompt: string): string { - return [ - "", - "This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.", - `schedule: ${name}`, - `fired_at: ${firedAt}`, - "", - "", - prompt, - ].join("\n"); -} +export const scheduledMessage = buildScheduledMessage; export interface SchedulerDeps { root: string; diff --git a/packages/server/src/runtime/session-manager.ts b/packages/server/src/runtime/session-manager.ts index cf3efa8..b767212 100644 --- a/packages/server/src/runtime/session-manager.ts +++ b/packages/server/src/runtime/session-manager.ts @@ -76,6 +76,8 @@ export interface RuntimeSession { compact(opts: { signal: AbortSignal }): AsyncGenerator; /** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */ compactability(): CompactAvailability; + /** Queues a mid-run steering message (core `Session.steer`); false when no Task is running. */ + steer(text: string): boolean; toolPermission(name: string): "r" | "rw" | undefined; /** * Out-of-band one-shot request for title generation (core `Session.generateTitle`, @@ -189,6 +191,12 @@ export interface SessionManagerDeps { log?: (line: string) => void; } +/** One queued follow-up task (`queueIfBusy`): the task input plus the per-turn thinking level it was posted with. */ +interface QueuedFollowUp { + input: OmniMessage[]; + thinkingLevel?: ThinkingLevelName; +} + /** Active-table entry: a loaded runtime Session plus its running state. */ interface RuntimeEntry { sessionId: string; @@ -209,6 +217,14 @@ interface RuntimeEntry { * the entry is discarded on its next idle access and re-resumed via the loader. */ generation: number; + /** + * Queued follow-up tasks (`queueIfBusy`): task inputs accepted while a Task/compaction was + * in progress, auto-started one at a time (in order) once the session returns to idle — + * ordinary tasks with normal semantics, unlike the mid-run steering queue. Each entry + * keeps the per-turn thinkingLevel it was posted with (applied at auto-start). + * Deliberately NOT discarded on abort: they are future tasks the user explicitly queued. + */ + followUps: QueuedFollowUp[]; /** Timestamp of last activity (refreshed on load / status flip / drive completion), used for idle-eviction checks. */ lastActivityMs: number; } @@ -333,6 +349,11 @@ export class SessionManager { return this.entries.get(sessionId)?.approvals.list() ?? []; } + /** Number of queued follow-up tasks (`queueIfBusy`) awaiting auto-start. */ + pendingFollowUpCount(sessionId: string): number { + return this.entries.get(sessionId)?.followUps.length ?? 0; + } + /** Number of Sessions for this Agent that are currently running / compacting. */ activeCountForAgent(projectId: string, agentId: string): number { let n = 0; @@ -356,6 +377,7 @@ export class SessionManager { abort: null, running: null, generation: this.generationOf(row.projectId, row.agentId), + followUps: [], lastActivityMs: Date.now(), }); } @@ -382,57 +404,105 @@ export class SessionManager { * background. Returns the current actual session_id (the new id after self-heal). * `opts.thinkingLevel` (optional, validated by the route) rides into this run's * `session.run` options — a per-turn parameter, applied to this Task only. + * With `queueIfBusy`, a busy session (running/compacting) enqueues the input as a + * follow-up instead of 409: it auto-starts as an ordinary next task once the session + * returns to idle (`queued: true` in the result; see startQueuedFollowUp), keeping its + * thinkingLevel for that auto-start. */ async startTask( sessionId: string, input: OmniMessage[], - opts?: { thinkingLevel?: ThinkingLevelName }, - ): Promise<{ sessionId: string }> { + opts?: { thinkingLevel?: ThinkingLevelName; queueIfBusy?: boolean }, + ): Promise<{ sessionId: string; queued: boolean }> { return this.withLock(sessionId, async () => { this.assertOpen(); this.assertAgentNotDeleting(sessionId); this.assertSessionNotDeleting(sessionId); const entry = await this.ensureEntry(sessionId); + if (entry.status !== "idle" && opts?.queueIfBusy) { + entry.followUps.push({ + input, + ...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}), + }); + entry.lastActivityMs = Date.now(); + // Re-publish the current state so subscribers pick up the new queued count (the + // input itself is published when the follow-up actually starts). + this.publishState(entry, entry.status); + return { sessionId: entry.sessionId, queued: true }; + } this.assertIdle(entry); - const channel = this.deps.channels.get(entry.sessionId); - const ac = new AbortController(); - entry.status = "running"; - entry.abort = ac; - entry.lastActivityMs = Date.now(); - // Publish the input messages first (visible to other subscribers; the Trace is - // persisted by the SDK), then flip the running status. - for (const msg of input) channel.publish(msg); - this.publishState(entry, "running"); - - const approve = makeApprove({ - // Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately). - getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask", - toolPermission: (name) => entry.session.toolPermission(name), - registry: entry.approvals, - publishRequest: (pending) => - this.publishEvent(entry, { - type: "approval_request", - toolCall: pending.toolCall, - ...(pending.origin !== undefined ? { origin: pending.origin } : {}), - }), - }); - const gen = entry.session.run(input, { - approve, - signal: ac.signal, - ...(opts?.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}), - }); - // Title material is collected by the core Session itself during run; here we only - // keep this call's input user text, used both as the "material present → attempt - // generation" criterion and as the fallback title source if the LLM call fails. - const userExcerpt = input - .filter(isPlainText("user")) - .map((m) => m.payload.text) - .join("\n"); - entry.running = this.drive(entry, gen, { userExcerpt }); - return { sessionId: entry.sessionId }; + this.launchTask(entry, input, opts?.thinkingLevel); + return { sessionId: entry.sessionId, queued: false }; }); } + /** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */ + private launchTask( + entry: RuntimeEntry, + input: OmniMessage[], + thinkingLevel?: ThinkingLevelName, + ): void { + const channel = this.deps.channels.get(entry.sessionId); + const ac = new AbortController(); + entry.status = "running"; + entry.abort = ac; + entry.lastActivityMs = Date.now(); + // Publish the input messages first (visible to other subscribers; the Trace is + // persisted by the SDK), then flip the running status. + for (const msg of input) channel.publish(msg); + this.publishState(entry, "running"); + + const approve = makeApprove({ + // Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately). + getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask", + toolPermission: (name) => entry.session.toolPermission(name), + registry: entry.approvals, + publishRequest: (pending) => + this.publishEvent(entry, { + type: "approval_request", + toolCall: pending.toolCall, + ...(pending.origin !== undefined ? { origin: pending.origin } : {}), + }), + }); + const gen = entry.session.run(input, { + approve, + signal: ac.signal, + ...(thinkingLevel !== undefined ? { thinkingLevel } : {}), + }); + // Title material is collected by the core Session itself during run; here we only + // keep this call's input user text, used both as the "material present → attempt + // generation" criterion and as the fallback title source if the LLM call fails. + const userExcerpt = input + .filter(isPlainText("user")) + .map((m) => m.payload.text) + .join("\n"); + entry.running = this.drive(entry, gen, { userExcerpt }); + } + + /** + * Auto-start of queued follow-ups: called from drive's finally whenever a Task or + * compaction finishes (abort included — follow-ups are future tasks the user queued, so + * an abort of the current run does not discard them). Re-validates everything under the + * session lock (another task may have snuck in, the server may be shutting down, the + * session may be getting deleted) and launches exactly one queued input — the next + * finish picks up the one after, keeping order and one-at-a-time semantics. + */ + private async startQueuedFollowUp(sessionId: string): Promise { + try { + await this.withLock(sessionId, async () => { + if (this.closed || this.deletingSessions.has(sessionId)) return; + const row = this.deps.sessions.findById(sessionId); + if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) return; + const entry = this.entries.get(sessionId); + if (!entry || entry.status !== "idle" || entry.followUps.length === 0) return; + const next = entry.followUps.shift()!; + this.launchTask(entry, next.input, next.thinkingLevel); + }); + } catch (err) { + this.log(`[followup] auto-start failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + /** Manually compact the context: 409 if already running; compaction output also flows into the SSE channel. */ async startCompact(sessionId: string): Promise<{ sessionId: string }> { return this.withLock(sessionId, async () => { @@ -469,6 +539,25 @@ export class SessionManager { return entry.approvals.decide(toolCallId, decision); } + /** + * Mid-run steering: forward the text to the running Session (core delivers it between + * turns as a standalone `[user_steering]` user message — no SSE event of its own; the + * message arrives through the stream the drive loop already publishes). 409 when the + * Session isn't running a Task (idle / compacting / not loaded) or the run finished in the + * race window — the caller falls back to submitting a normal task. + */ + steer(sessionId: string, text: string): void { + const entry = this.entries.get(sessionId); + if (!entry || entry.status !== "running" || !entry.session.steer(text)) { + throw new HttpError( + 409, + "not_running", + "This Session has no Task in progress; send the message as a new task instead.", + ); + } + entry.lastActivityMs = Date.now(); + } + /** * Interrupt the current Task/compaction: pending approvals converge to deny first, * then the AbortSignal fires. Returns false if nothing is in progress (the route @@ -581,6 +670,7 @@ export class SessionManager { sweepIdle(now: number = Date.now(), idleMs: number = ENTRY_IDLE_MS): void { for (const [key, entry] of this.entries) { if (entry.status !== "idle" || entry.approvals.size !== 0 || entry.running !== null) continue; + if (entry.followUps.length > 0) continue; // queued follow-ups must not be evicted with the entry if (now - entry.lastActivityMs <= idleMs) continue; this.entries.delete(key); } @@ -693,6 +783,7 @@ export class SessionManager { abort: null, running: null, generation, + followUps: [], lastActivityMs: Date.now(), }; this.entries.set(currentId, entry); @@ -855,6 +946,10 @@ export class SessionManager { fallbackText: titleSource.userExcerpt, }); } + // Queued follow-ups: whenever a run finishes (Task or compaction, abort included), + // the next queued input auto-starts as an ordinary task. Fire-and-forget — it + // revalidates under the session lock. + if (entry.followUps.length > 0) void this.startQueuedFollowUp(entry.sessionId); // A subagent's title is likewise generated by the model, with material being the // subagent's **own conversation**: the prompt that spawned it plus its own reply // (material the parent Session collects belongs to the parent, hence the explicit @@ -942,7 +1037,9 @@ export class SessionManager { } private publishState(entry: RuntimeEntry, state: SessionStatus): void { - this.publishEvent(entry, { type: "task_state", state }); + // Every state flip also reports the queued follow-up count, so subscribers can render + // the "N queued" hint without a dedicated event type. + this.publishEvent(entry, { type: "task_state", state, queued: entry.followUps.length }); } private publishEvent(entry: RuntimeEntry, event: ServerEvent): void { diff --git a/packages/server/src/runtime/title-generator.ts b/packages/server/src/runtime/title-generator.ts index 00fbc6b..35452f2 100644 --- a/packages/server/src/runtime/title-generator.ts +++ b/packages/server/src/runtime/title-generator.ts @@ -137,7 +137,7 @@ export class TitleGenerator implements TitleNotifier { /** Fallback title: take the material's first non-empty line, sanitize and truncate; if sanitizing empties it out (pure punctuation, etc.) fall back to the truncated original text; returns null if all-whitespace. */ function fallbackTitle(text: string): string | null { - // Strip machine markers first: a skill invocation prepends a `` block, so the + // Strip machine markers first: a skill invocation prepends a `[use_skills]` block, so the // raw first non-empty line would otherwise be that marker rather than the user's request. const firstLine = stripConversationMarkers(text) .split("\n") diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index d585b26..295c2ab 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -120,6 +120,7 @@ export class SessionService { createdAt: row.createdAt, status: this.deps.manager.statusOf(row.sessionId), pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId), + pendingFollowUpCount: this.deps.manager.pendingFollowUpCount(row.sessionId), hasTrace, archived: (row.archivedAt ?? null) !== null, }; @@ -394,7 +395,7 @@ export class SessionService { * Absolute path of a Session's **latest** Trace file (the current context shard); * undefined when no Trace exists. Costs a directory walk, so only the single-session * GET includes it in the DTO (see SessionInfo.tracePath) — the web's `/model` switch - * hands it to the new session's `` block so the model can read the + * hands it to the new session's `[model_switch_from]` block so the model can read the * source history itself when it needs it. */ async latestTracePath(row: SessionRow): Promise { diff --git a/packages/server/src/services/trace-service.ts b/packages/server/src/services/trace-service.ts index 465d11d..45343ae 100644 --- a/packages/server/src/services/trace-service.ts +++ b/packages/server/src/services/trace-service.ts @@ -11,7 +11,12 @@ */ import fs from "node:fs/promises"; import path from "node:path"; -import { agentsDir, readTraceTolerant, tracesDir } from "@prismshadow/penguin-core"; +import { + agentsDir, + parseUserSteeringText, + readTraceTolerant, + tracesDir, +} from "@prismshadow/penguin-core"; import type { OmniMessage } from "@prismshadow/penguin-core"; import type { AgentTracesResponse, @@ -332,9 +337,22 @@ export class TraceService { // images" = multiple messages; only the **first** one counts (once // pendingFrom is set, it's not changed again), otherwise the turn's start // would shift to the last image. + // Mid-run steering (`[user_steering]`-wrapped user text, delivered between turns of a + // running Task): never a turn starter — same exclusion idea as the compaction summary — + // and it forces the next request to be a continuation (a steering-only continuation + // turn has no preceding tool call, but it is still the same Task). + const isSteeringText = + !hasOrigin && + msg.type === "model_msg" && + p.type === "text" && + p.role === "user" && + typeof p.text === "string" && + parseUserSteeringText(p.text) !== null; + if (isSteeringText) continuation = true; const startsUserTurn = !hasOrigin && !compactionActive && + !isSteeringText && msg.type === "model_msg" && ((p.type === "text" && p.role === "user") || p.type === "image_url"); const startsCompactionTurn = @@ -370,7 +388,7 @@ export class TraceService { if (compactionActive) t.compaction = true; // This turn is a compaction turn // This turn's duration starts at the first request_begin. It doesn't // use the timestamp of the user Prompt / compaction summary or other - // user text: `` is created during compaction but only + // user text: `[context_summary]` is created during compaction but only // written to disk on the next run, so resuming the next day would // stretch the first turn out by a whole day for no reason; the Prompt // to request-dispatch gap is only ever milliseconds anyway. diff --git a/packages/server/test/followup.test.ts b/packages/server/test/followup.test.ts new file mode 100644 index 0000000..aa345d0 --- /dev/null +++ b/packages/server/test/followup.test.ts @@ -0,0 +1,100 @@ +/** + * Integration tests for the follow-up queue (POST /api/sessions/:id/tasks with + * `queueIfBusy`): + * - busy session → 202 `{queued: true}`, input held server-side, auto-started as an + * ordinary task once the current run finishes; + * - idle session → the flag is a no-op (`queued: false`, task starts directly). + * (The queued count on task_state events is covered by the session-manager unit tests.) + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { approvalDecision, assistantText, toolCall } from "@prismshadow/penguin-core"; +import type { ApproveFn, OmniMessage } from "@prismshadow/penguin-core"; +import type { TaskCreateResponse } from "../src/api/types.js"; +import type { SessionRow } from "../src/db/repos/sessions.js"; +import type { RuntimeSession } from "../src/runtime/session-manager.js"; +import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js"; +import type { TestApp } from "./helpers.js"; + +const SID = "session-2026-07-06-10-00-00-eeff0001"; + +/** Fake Session that parks on one approval per run (keeps the Task running) and records each run's input. */ +function parkingFakeSession(sessionId: string, runs: string[][]): RuntimeSession { + return { + sessionId, + toolPermission: () => "rw", + generateTitle: async () => ({ title: null, usage: null }), + compactability: () => "ok" as const, + steer: () => false, + async *run(input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) { + runs.push(input.map((m) => (m.payload as { text?: string }).text ?? "")); + const tc = toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-fu" }); + yield tc; + const decision = await opts.approve(tc); + yield approvalDecision(decision, "tc-fu"); + yield assistantText("done"); + }, + async *compact() {}, + }; +} + +describe("follow-up queue route", () => { + let t: TestApp; + let api: ReturnType; + let runs: string[][]; + + beforeEach(async () => { + t = await createTestApp(); + const { cookie } = await provisionUser(t.app, "queuer"); + api = apiClient(t.app, cookie); + const row: SessionRow = { + sessionId: SID, + projectId: "queuer-default_project", + agentId: "default_agent", + provider: "custom", + modelId: "m1", + workspace: "/tmp/w", + approvalMode: "always-ask", + title: null, + createdAt: new Date().toISOString(), + }; + t.deps.sessionsRepo.insert(row); + runs = []; + t.deps.manager.adopt(row, parkingFakeSession(SID, runs)); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it("busy → 202 queued:true and auto-starts after the current run; idle → queued:false", async () => { + // Idle: the flag is a no-op, the task starts directly. + const direct = await api.post(`/api/sessions/${SID}/tasks`, { + input: [{ type: "text", text: "task 1" }], + queueIfBusy: true, + }); + expect(direct.status).toBe(202); + expect(((await direct.json()) as TaskCreateResponse).queued).toBe(false); + await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1); + + // Busy: held server-side (a plain POST would 409). + const queuedRes = await api.post(`/api/sessions/${SID}/tasks`, { + input: [{ type: "text", text: "follow-up" }], + queueIfBusy: true, + }); + expect(queuedRes.status).toBe(202); + expect(((await queuedRes.json()) as TaskCreateResponse).queued).toBe(true); + expect(t.deps.manager.pendingFollowUpCount(SID)).toBe(1); + expect( + (await api.post(`/api/sessions/${SID}/tasks`, { input: [{ type: "text", text: "x" }] })) + .status, + ).toBe(409); + + // Finish run 1: the follow-up auto-starts as an ordinary task, in order. + t.deps.manager.decideApproval(SID, "tc-fu", "allow"); + await waitFor(() => runs.length === 2); + expect(runs[1]).toEqual(["follow-up"]); + expect(t.deps.manager.pendingFollowUpCount(SID)).toBe(0); + await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1); + t.deps.manager.decideApproval(SID, "tc-fu", "allow"); + await waitFor(() => t.deps.manager.statusOf(SID) === "idle"); + }); +}); diff --git a/packages/server/test/scheduler.test.ts b/packages/server/test/scheduler.test.ts index 4071a5d..3d4ec88 100644 --- a/packages/server/test/scheduler.test.ts +++ b/packages/server/test/scheduler.test.ts @@ -148,8 +148,8 @@ describe("scheduler", () => { await scheduler.tickOnce(); expect(started).toHaveLength(1); expect(started[0]?.sessionId).toBe("session-1"); - // Trigger input = source block + the prompt body (tells the model this is a scheduled task). - expect(started[0]?.text).toContain(""); + // Trigger input = [scheduled_task] source block + the prompt body (tells the model this is a scheduled task). + expect(started[0]?.text).toContain("[scheduled_task]"); expect(started[0]?.text).toContain("schedule: report"); expect(started[0]?.text).toContain("Write the daily report"); expect(events.map((e) => e.event.type)).toContain("schedule_fired"); diff --git a/packages/server/test/session-manager.test.ts b/packages/server/test/session-manager.test.ts index 206c1e9..e24646d 100644 --- a/packages/server/test/session-manager.test.ts +++ b/packages/server/test/session-manager.test.ts @@ -57,6 +57,7 @@ function approvalFakeSession(sessionId: string, toolName = "exec_command"): Runt toolPermission: (name) => (name === "read_tool" ? "r" : "rw"), generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) { const tc = toolCall({ name: toolName, arguments: "{}", toolCallId: "tc-1" }); yield tc; @@ -163,6 +164,7 @@ describe("session-manager", () => { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run(_input: OmniMessage[], opts: { thinkingLevel?: string }) { seen.push(opts.thinkingLevel); yield assistantText("ok"); @@ -189,6 +191,7 @@ describe("session-manager", () => { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run(): AsyncGenerator { yield requestBegin(); yield toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-1" }); @@ -230,6 +233,136 @@ describe("session-manager", () => { await waitFor(() => manager.statusOf("session-1") === "idle"); }); + it("steer: forwards to the running session; idle / lost race → 409 not_running", async () => { + const steered: string[] = []; + const fake = approvalFakeSession("session-1"); + fake.steer = (text: string) => { + steered.push(text); + return true; + }; + const manager = makeManager(loaderOf(fake)); + const steerErr = (text: string): unknown => { + try { + manager.steer("session-1", text); + return null; + } catch (e) { + return e; + } + }; + + // Not running (entry not even loaded): 409 not_running (typed like task_in_progress). + const before = steerErr("early") as HttpError; + expect(before.status).toBe(409); + expect(before.code).toBe("not_running"); + + await manager.startTask("session-1", [userText("go")]); + await waitFor(() => manager.pendingApprovalCount("session-1") === 1); + // Running: forwarded to the core session (no SSE event of its own). + expect(steerErr("focus on tests")).toBeNull(); + expect(steered).toEqual(["focus on tests"]); + + // The core session lost the race (its run just finished): also 409, so the + // caller falls back to a normal task. + fake.steer = () => false; + expect((steerErr("late") as HttpError).code).toBe("not_running"); + + manager.decideApproval("session-1", "tc-1", "allow"); + await waitFor(() => manager.statusOf("session-1") === "idle"); + // Idle again after the run: 409. + expect((steerErr("post") as HttpError).code).toBe("not_running"); + }); + + it("queueIfBusy: enqueues while running, auto-starts in order after each finish; abort keeps the queue", async () => { + sessions.updateApprovalMode("session-1", "always-ask"); + const runInputs: string[][] = []; + const fake = approvalFakeSession("session-1"); + const origRun = fake.run.bind(fake); + fake.run = function (input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) { + runInputs.push(input.map((m) => (m.payload as { text?: string }).text ?? "")); + return origRun(input, opts); + }; + const manager = makeManager(loaderOf(fake)); + const events = capture("session-1"); + + const first = await manager.startTask("session-1", [userText("task 1")]); + expect(first.queued).toBe(false); + await waitFor(() => manager.pendingApprovalCount("session-1") === 1); + + // Busy + queueIfBusy: held server-side instead of 409 (order preserved); without the + // flag the 409 mutual exclusion is unchanged. + const q1 = await manager.startTask("session-1", [userText("follow-up 1")], { + queueIfBusy: true, + }); + const q2 = await manager.startTask("session-1", [userText("follow-up 2")], { + queueIfBusy: true, + }); + expect([q1.queued, q2.queued]).toEqual([true, true]); + expect(manager.pendingFollowUpCount("session-1")).toBe(2); + const conflict = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e); + expect((conflict as HttpError).code).toBe("task_in_progress"); + + // Abort the running task: queued follow-ups are future tasks — NOT discarded; the next + // one auto-starts as an ordinary task once the aborted run settles. + manager.abortTask("session-1"); + await waitFor(() => runInputs.length === 2); + expect(runInputs[1]).toEqual(["follow-up 1"]); + expect(manager.pendingFollowUpCount("session-1")).toBe(1); + + // Finishing each run starts the next queued input, in order, one at a time. + await waitFor(() => manager.pendingApprovalCount("session-1") === 1); + manager.decideApproval("session-1", "tc-1", "allow"); + await waitFor(() => runInputs.length === 3); + expect(runInputs[2]).toEqual(["follow-up 2"]); + await waitFor(() => manager.pendingApprovalCount("session-1") === 1); + manager.decideApproval("session-1", "tc-1", "allow"); + await waitFor( + () => + manager.statusOf("session-1") === "idle" && manager.pendingFollowUpCount("session-1") === 0, + ); + + // task_state events report the queued count (for the input area's "N queued" hint). + const states = serverEvents(events).filter((e) => e.type === "task_state"); + expect(states.some((s) => s.queued === 2)).toBe(true); + expect(states[states.length - 1]).toMatchObject({ state: "idle", queued: 0 }); + }); + + it("queueIfBusy during compaction: the queue drains once the compaction ends", async () => { + const runInputs: string[][] = []; + let releaseCompact: (() => void) | null = null; + const fake: RuntimeSession = { + sessionId: "session-1", + toolPermission: () => "rw", + generateTitle: async () => ({ title: null, usage: null }), + compactability: () => "ok" as const, + steer: () => false, + // eslint-disable-next-line require-yield + async *run(input: OmniMessage[]): AsyncGenerator { + runInputs.push(input.map((m) => (m.payload as { text?: string }).text ?? "")); + }, + async *compact(): AsyncGenerator { + await new Promise((resolve) => { + releaseCompact = resolve; + }); + yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 }); + yield compactionEnd({ reason: "manual", mode: "summarize", status: "completed" }); + }, + }; + const manager = makeManager(loaderOf(fake)); + await manager.startCompact("session-1"); + await waitFor(() => releaseCompact !== null); + + const q = await manager.startTask("session-1", [userText("after compact")], { + queueIfBusy: true, + }); + expect(q.queued).toBe(true); + expect(manager.pendingFollowUpCount("session-1")).toBe(1); + + releaseCompact!(); + await waitFor(() => runInputs.length === 1 && manager.pendingFollowUpCount("session-1") === 0); + expect(runInputs[0]).toEqual(["after compact"]); + await waitFor(() => manager.statusOf("session-1") === "idle"); + }); + it("always-ask: registers a pending approval and pushes approval_request; continues after the decision", async () => { const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const events = capture("session-1"); @@ -283,6 +416,7 @@ describe("session-manager", () => { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run() { // The parent-level run_subagent call (no origin): its prompt becomes the sub-session title. yield toolCall({ @@ -358,6 +492,7 @@ describe("session-manager", () => { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run(_input, opts) { const tc1 = toolCall({ name: "t1", arguments: "{}", toolCallId: "tc-1" }); yield tc1; @@ -595,6 +730,7 @@ describe("session-manager", () => { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run() { yield thinkingMessage("thinking"); yield assistantText("answer A"); @@ -648,6 +784,7 @@ describe("session-manager", () => { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run() { yield assistantText("x".repeat(600)); // Sub-session text doesn't count toward the main-session body threshold @@ -698,6 +835,7 @@ describe("session-manager", () => { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run() { yield toolCall({ name: "run_subagent", diff --git a/packages/server/test/sse-stream.test.ts b/packages/server/test/sse-stream.test.ts index 25d7c51..61e646e 100644 --- a/packages/server/test/sse-stream.test.ts +++ b/packages/server/test/sse-stream.test.ts @@ -63,6 +63,7 @@ function approvalFakeSession(sessionId: string): RuntimeSession { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) { const tc = toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-sse" }); yield tc; @@ -107,7 +108,7 @@ describe("sse-stream", () => { it("FD-1: a new subscription's first frame is always the task_state snapshot (idle)", async () => { const frames = await readSseFrames(await getStream(), 1); expect(frames[0]!.event).toBe("server_event"); - expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "idle" }); + expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "idle", queued: 0 }); expect(frames[0]!.id).toMatch(/^[0-9a-f]{8}-\d+$/); // FD-2: opaque string id }); @@ -117,7 +118,11 @@ describe("sse-stream", () => { await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1); const frames = await readSseFrames(await getStream(), 2); - expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "running" }); + expect(JSON.parse(frames[0]!.data)).toEqual({ + type: "task_state", + state: "running", + queued: 0, + }); const approval = JSON.parse(frames[1]!.data) as { type: string; toolCall: { payload: { tool_call_id: string } }; @@ -136,7 +141,7 @@ describe("sse-stream", () => { 2, ); expect(JSON.parse(frames[0]!.data)).toEqual({ type: "resync_required" }); - expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle" }); + expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle", queued: 0 }); }); it("FD-2: same-epoch Last-Event-ID hitting the buffer → replays later events, then the task_state snapshot", async () => { @@ -146,6 +151,6 @@ describe("sse-stream", () => { const frames = await readSseFrames(await getStream({ "Last-Event-ID": first.id }), 2); expect(frames[0]!.event).toBeUndefined(); // replayed OmniMessage expect((JSON.parse(frames[0]!.data) as { payload: { text: string } }).payload.text).toBe("m2"); - expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle" }); + expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle", queued: 0 }); }); }); diff --git a/packages/server/test/steer.test.ts b/packages/server/test/steer.test.ts new file mode 100644 index 0000000..4903a0f --- /dev/null +++ b/packages/server/test/steer.test.ts @@ -0,0 +1,97 @@ +/** + * Integration tests for POST /api/sessions/:id/steer (mid-run steering): + * - 202 while a Task is running, forwarding the trimmed text to the core session; + * - 400 for empty / non-string text; + * - 409 not_running when the Session is idle (the frontend then falls back to a + * normal task POST); + * - 404 for foreign/unknown sessions (via the shared resolveSession lookup). + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { approvalDecision, assistantText, toolCall, userText } from "@prismshadow/penguin-core"; +import type { ApproveFn, OmniMessage } from "@prismshadow/penguin-core"; +import type { SessionRow } from "../src/db/repos/sessions.js"; +import type { RuntimeSession } from "../src/runtime/session-manager.js"; +import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js"; +import type { TestApp } from "./helpers.js"; + +const SID = "session-2026-07-06-10-00-00-ccdd0001"; + +/** Fake Session that parks on one approval (keeps the Task running) and records steer calls. */ +function steeringFakeSession(sessionId: string, steered: string[]): RuntimeSession { + return { + sessionId, + toolPermission: () => "rw", + generateTitle: async () => ({ title: null, usage: null }), + compactability: () => "ok" as const, + steer: (text: string) => { + steered.push(text); + return true; + }, + async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) { + const tc = toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-steer" }); + yield tc; + const decision = await opts.approve(tc); + yield approvalDecision(decision, "tc-steer"); + yield assistantText("done"); + }, + async *compact() {}, + }; +} + +describe("steer route", () => { + let t: TestApp; + let api: ReturnType; + let steered: string[]; + + beforeEach(async () => { + t = await createTestApp(); + const { cookie } = await provisionUser(t.app, "steerer"); + api = apiClient(t.app, cookie); + const row: SessionRow = { + sessionId: SID, + projectId: "steerer-default_project", + agentId: "default_agent", + modelId: "m1", + provider: "custom", + workspace: "/tmp/w", + approvalMode: "always-ask", + title: null, + createdAt: new Date().toISOString(), + }; + t.deps.sessionsRepo.insert(row); + steered = []; + t.deps.manager.adopt(row, steeringFakeSession(SID, steered)); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it("idle → 409 not_running (the frontend falls back to a normal task POST)", async () => { + const res = await api.post(`/api/sessions/${SID}/steer`, { text: "hello" }); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("not_running"); + expect(steered).toEqual([]); + }); + + it("running → 202, the trimmed text reaches the core session; empty text → 400", async () => { + await t.deps.manager.startTask(SID, [userText("go")]); + await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1); + + expect((await api.post(`/api/sessions/${SID}/steer`, { text: " " })).status).toBe(400); + expect((await api.post(`/api/sessions/${SID}/steer`, { text: 42 })).status).toBe(400); + expect(steered).toEqual([]); + + const ok = await api.post(`/api/sessions/${SID}/steer`, { text: " focus on tests " }); + expect(ok.status).toBe(202); + expect(steered).toEqual(["focus on tests"]); + + t.deps.manager.decideApproval(SID, "tc-steer", "allow"); + await waitFor(() => t.deps.manager.statusOf(SID) === "idle"); + }); + + it("unknown session → 404", async () => { + const res = await api.post(`/api/sessions/session-ghost/steer`, { text: "x" }); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/server/test/title-generator.test.ts b/packages/server/test/title-generator.test.ts index b5dc213..97ddece 100644 --- a/packages/server/test/title-generator.test.ts +++ b/packages/server/test/title-generator.test.ts @@ -164,14 +164,24 @@ describe("title-generator", () => { await waitFor(() => recorded.length > 0); }); - it("fallback strips a leading block so the marker never becomes the title", async () => { + it("fallback strips a leading [use_skills] block so the marker never becomes the title", async () => { + const calls = { count: 0, args: [] as unknown[] }; + const gen = makeGenerator(); + gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { + fallbackText: "[use_skills]\nskills: web-design\n[/use_skills]\n做一个落地页", + }); + await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); + // Not "[use_skills]" — the marker block is removed before the first line is taken. + expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页"); + }); + + it("fallback also strips the legacy angle-bracket block (material from old Traces)", async () => { const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { fallbackText: "\nskills: web-design\n\n做一个落地页", }); await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); - // Not "" — the marker block is removed before the first line is taken. expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页"); }); diff --git a/packages/server/test/trace-service.test.ts b/packages/server/test/trace-service.test.ts index 288f939..0ed2d6a 100644 --- a/packages/server/test/trace-service.test.ts +++ b/packages/server/test/trace-service.test.ts @@ -340,7 +340,7 @@ describe("trace-service", () => { // (the frontend uses this to list it on this turn's card), but the duration // only looks at the LLM request — the start point is the first request_begin, // and the user text's timestamp doesn't participate (the compaction summary - // `` is created during compaction but only persisted on the + // `[context_summary]` is created during compaction but only persisted on the // next run; using it as the start point would stretch the first turn out for // no reason). Also, if the turn list were built only from turns with "a model // segment or a tool span", a turn that fails outright with no output at all @@ -584,6 +584,37 @@ describe("trace-service", () => { expect(a.requests.map((r) => r.taskIndex)).toEqual([0, 0, 1]); }); + it("Task grouping: [user_steering] user texts never start a new Task (steering continuation stays in the same Task)", async () => { + const T = (sec: string) => `2026-07-05T10:03:${sec}Z`; + await writeTraceFile(root, P, A, "2026-07-05", S, 12, [ + sessionMeta(metaPayload()), + // Task 0, round 1: calls a tool; a steering message rides alongside the tool output. + at(T("00.000"), userText("q1")), + at(T("01.000"), requestBegin()), + at(T("02.000"), toolCall({ name: "read_file", arguments: "{}", toolCallId: "t1" })), + at(T("02.500"), requestEnd("completed")), + at(T("03.000"), toolCallOutput({ output: "o", toolCallId: "t1" })), + at(T("03.200"), userText("[user_steering]\nalso check the tests\n[/user_steering]")), + at(T("03.500"), requestBegin()), + at(T("04.000"), assistantText("answer 1")), + at(T("04.500"), requestEnd("completed")), + // Loop-end steering: the answer round produced no tool call — a plain user text here + // would start a new Task, but the steering continuation stays in Task 0. + at(T("05.000"), userText("[user_steering]\none more thing\n[/user_steering]")), + at(T("05.500"), requestBegin()), + at(T("06.000"), assistantText("answer 2")), + at(T("06.500"), requestEnd("completed")), + // A real new user turn afterwards starts Task 1 as usual. + at(T("20.000"), userText("q2")), + at(T("21.000"), requestBegin()), + at(T("22.000"), assistantText("answer 3")), + at(T("22.500"), requestEnd("completed")), + ]); + const a = await service.analyze(P, A, S, 12); + expect(a.requests.map((r) => r.taskIndex)).toEqual([0, 0, 0, 1]); + expect(a.modelSegments.map((s) => s.taskIndex)).toEqual([0, 0, 0, 1]); + }); + // Compaction is its own turn: the previous turn called a tool and would // otherwise "continue", but compaction_begin breaks that continuation, so the // compaction request lands on a new taskIndex. A successful compaction splits @@ -604,7 +635,7 @@ describe("trace-service", () => { compactionBegin({ reason: "context", mode: "summarize", context: 1, turns: 1 }), ), at(T("04.500"), requestBegin()), - at(T("05.000"), assistantText("…")), + at(T("05.000"), assistantText("[summary]…[/summary]")), at(T("05.500"), requestEnd("completed")), at(T("06.000"), compactionEnd({ reason: "context", mode: "summarize", status: "completed" })), ]); diff --git a/packages/server/test/vault.test.ts b/packages/server/test/vault.test.ts index c04ad22..cb3a8cb 100644 --- a/packages/server/test/vault.test.ts +++ b/packages/server/test/vault.test.ts @@ -21,6 +21,7 @@ function fakeRuntimeSession(sessionId: string): RuntimeSession { toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, + steer: () => false, async *run() {}, async *compact() {}, }; diff --git a/packages/skills/skills/agent-optimization/SKILL.md b/packages/skills/skills/agent-optimization/SKILL.md index fcb2d67..ea0f1d8 100644 --- a/packages/skills/skills/agent-optimization/SKILL.md +++ b/packages/skills/skills/agent-optimization/SKILL.md @@ -3,8 +3,8 @@ name: agent-optimization description: Improve an Agent State from direct feedback or versioned multi-Case Benchmark scores and score-linked Traces. short_description: Improve an Agent from feedback or measured Benchmark results. short_description_zh: 根据反馈或 Benchmark 结果改进 Agent。 -version: 4 -updated: 2026-07-25T00:00:00Z +version: 5 +updated: 2026-07-26T00:00:00Z --- # Agent Optimization @@ -19,7 +19,7 @@ Benchmark mode requires a top-level Session with `run_subagent`, a complete base ## Pick the target Agent -A one-shot request normally names the target. A delegated request begins with `Caller agent: `, and an @-mention handoff contains ``. When one-shot mode has no explicit target, use that caller or origin; if neither exists, ask. Benchmark mode always requires an explicit Test Agent and Benchmark. +A one-shot request normally names the target. A delegated request begins with `Caller agent: `, and an @-mention handoff contains `[handoff_from]`. When one-shot mode has no explicit target, use that caller or origin; if neither exists, ask. Benchmark mode always requires an explicit Test Agent and Benchmark. Resolve paths from the Environment's App Data Dir without recursively discovering the Project: diff --git a/packages/web/e2e/layout.spec.mjs b/packages/web/e2e/layout.spec.mjs index b116c5d..78d4dea 100644 --- a/packages/web/e2e/layout.spec.mjs +++ b/packages/web/e2e/layout.spec.mjs @@ -28,6 +28,7 @@ import { test, expect } from "@playwright/test"; import { provisionAndLogin } from "./auth.mjs"; const BASE = process.env.BASE_URL; +const MOCK = process.env.MOCK_URL; const U = "layoutuser"; const P = "password123"; @@ -256,7 +257,13 @@ test("layout: mobile chat dropdowns stay inside the viewport", async ({ page }) data: { defaultModel: { provider: "custom", modelId: "claude-4-8" }, models: [ - { provider: "custom", modelId: "claude-4-8", apiKey: "sk-mock", contextWindow: 200000 }, + { + provider: "custom", + modelId: "claude-4-8", + apiKey: "sk-mock", + baseUrl: MOCK, + contextWindow: 200000, + }, { provider: "openai", modelId: "gpt-5.5", apiKey: "sk-mock2" }, { provider: "custom", @@ -289,6 +296,24 @@ test("layout: mobile chat dropdowns stay inside the viewport", async ({ page }) expect(m.scrolled, `${name}: page not scrolled sideways`).toBe(0); const d = await docWidths(page); expect(d.scrollWidth, `${name}: no horizontal overflow`).toBeLessThanOrEqual(d.clientWidth); + // On-screen coordinates alone don't prove the panel is *painted*: an ancestor with a + // non-visible overflow (the composer toolbar scrolls horizontally on phones) clips it + // while leaving its rect intact. Hit-test a point inside the panel's **visible** area — + // the rect's intersection with the viewport, since a long menu may legitimately extend + // past the fold on a page that scrolls. + await expect(panel, `${name}: panel visible`).toBeInViewport(); + const hit = await panel.evaluate((el) => { + const r = el.getBoundingClientRect(); + const top = Math.max(r.top, 0); + const bottom = Math.min(r.bottom, window.innerHeight); + if (bottom - top < 2) return "no visible area"; + const x = Math.round(Math.max(r.left, 0) + Math.min(r.width / 2, 40)); + const y = Math.round(top + Math.min((bottom - top) / 2, 20)); + const hitEl = document.elementFromPoint(x, y); + if (!hitEl) return "nothing at the sampled point"; + return el.contains(hitEl) || el === hitEl ? true : `covered by ${hitEl.tagName}`; + }); + expect(hit, `${name}: panel not clipped by an ancestor`).toBe(true); }; const open = async (label, name) => { await page.locator(`button[aria-label="${label}"]`).click(); @@ -329,19 +354,47 @@ test("layout: mobile chat dropdowns stay inside the viewport", async ({ page }) await close(); } - // Session state (bottom-docked composer, menus open upward): approval + skills render there - // too and share the left-anchored geometry; still at 390x844 from the loop above. + // Session state (bottom-docked composer, menus open upward). The composer toolbar scrolls + // horizontally at phone widths, so every picker in it must escape that clipping ancestor — + // checked idle **and** while a Task runs (an always-ask session parks on a pending + // approval), at the narrowest widths we support. const sess = await ( await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, { - data: { provider: "custom", modelId: "claude-4-8" }, + data: { provider: "custom", modelId: "claude-4-8", approvalMode: "always-ask" }, }) ).json(); - await page.goto(`${BASE}/chat/${sess.session.sessionId}`); - await page.getByPlaceholder(/Type a message/).waitFor(); - await open("Approval mode", "approval @session"); - await close(); - await open("Skills", "skills @session"); - await close(); + const sessionPickers = ["Approval mode", "Skills", "More settings", "Thinking level"]; + for (const vp of [ + { width: 320, height: 640 }, + { width: 375, height: 667 }, + ]) { + await page.setViewportSize(vp); + await page.goto(`${BASE}/chat/${sess.session.sessionId}`); + await page.getByPlaceholder(/Type a message/).waitFor(); + for (const label of sessionPickers) { + await open(label, `${label} @session ${vp.width}`); + await close(); + } + } + + // Running state: send a message, wait for the pending approval (the run stays running), then + // re-open every picker. The composer stays enabled for steering, so the row is at its + // busiest here — this is the state the toolbar overflowed in. + await page.getByPlaceholder(/Type a message/).fill("Help me set up @theme"); + await page.locator('button[aria-label="Send"]').click(); + await page.getByRole("button", { name: /^Allow$/ }).waitFor(); + // Skills are deliberately locked mid-run; the rest must still open. + await expect(page.locator('button[aria-label="Skills"]')).toBeDisabled(); + for (const label of ["Approval mode", "More settings", "Thinking level"]) { + await open(label, `${label} @running 375`); + await close(); + } + // …and the toolbar itself still fits, with the merged Stop/Send button reachable. + const running = await docWidths(page); + expect(running.scrollWidth, "running toolbar: no horizontal overflow").toBeLessThanOrEqual( + running.clientWidth, + ); + await expect(page.locator('button[aria-label="Stop"]')).toBeVisible(); }); test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => { diff --git a/packages/web/e2e/mock-llm.mjs b/packages/web/e2e/mock-llm.mjs index 5ca8a9b..d81c1e9 100644 --- a/packages/web/e2e/mock-llm.mjs +++ b/packages/web/e2e/mock-llm.mjs @@ -93,7 +93,7 @@ const server = http.createServer((req, res) => { // the connection (no message_stop), so AgentHub reports "stream incomplete" -> GenerativeModel // resolves it as malformed. On reconnect the engine **resends the input verbatim** — in this // scenario the failed attempt only has a half tool_call (never committed to the ledger), so - // the retry request carries no block and is byte-for-byte identical to the + // the retry request carries no [turn_retried] block and is byte-for-byte identical to the // first request; the mock can only tell them apart by request count (see the malformedTurns counter). const wantsMalformed = flat.includes("bad stream test"); diff --git a/packages/web/e2e/skills.spec.mjs b/packages/web/e2e/skills.spec.mjs index 7449eb6..2ad1edb 100644 --- a/packages/web/e2e/skills.spec.mjs +++ b/packages/web/e2e/skills.spec.mjs @@ -17,7 +17,7 @@ * - selections are written into the draft (#74 comment): in draft state, checking the dropdown * then reloading keeps both the body and the selection; in session state, selecting via slash * then reloading likewise persists (keyed by user x Session); - * - sending the prefilled body directly -> the message stream collapses the block + * - sending the prefilled body directly -> the message stream collapses the [use_skills] block * into a "使用技能" ("Use skills") banner, the invoke text still renders normally, the stored * message really does start with the block, and the selection clears once sending succeeds; * - slash invocation: typing / shows a skill command item, and pressing Enter selects @@ -243,14 +243,14 @@ test("skills: library groups and cards -> manage-install Modal -> quick-invoke p await expect(row("agent-optimization")).toHaveAttribute("aria-pressed", "true"); await page.keyboard.press("Escape"); - // The stored message really does start with the block (the banner is only a + // The stored message really does start with the [use_skills] block (the banner is only a // rendering-layer collapse; Trace/storage keeps the raw text), with the body being the invoke // text prefilled by quick invoke. const messages = await ( await page.request.get(`${BASE}/api/sessions/${sessionId}/messages`) ).json(); const flat = JSON.stringify(messages); - expect(flat, "stored message keeps the block").toContain(""); + expect(flat, "stored message keeps the [use_skills] block").toContain("[use_skills]"); expect(flat, "block lists the selected skill").toContain("skills: agent-creation"); expect(flat, "prefilled body follows the block").toContain("使用 agent-creation 技能"); }); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 2676a53..f45528e 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -51,6 +51,7 @@ import type { SessionTracesResponse, SkillInstallRequest, SkillLibraryResponse, + SteerRequest, TaskCreateRequest, TaskCreateResponse, TraceAnalysisResponse, @@ -264,6 +265,13 @@ export const postAbort = (sessionId: string) => body: {}, }); +/** Mid-run steering: queues a message for the running Task (delivered between turns as a standalone `[user_steering]` user message); 409 not_running when no Task is in progress. */ +export const postSteer = (sessionId: string, body: SteerRequest) => + apiFetch(`/api/sessions/${encodeURIComponent(sessionId)}/steer`, { + method: "POST", + body, + }); + export const postCompact = (sessionId: string) => // Same shape as tasks: the response carries the actual current session_id (a new id after self-healing; the frontend updates its route accordingly). apiFetch(`/api/sessions/${encodeURIComponent(sessionId)}/compact`, { diff --git a/packages/web/src/components/ui/dropdown.tsx b/packages/web/src/components/ui/dropdown.tsx index 0865460..9e20d51 100644 --- a/packages/web/src/components/ui/dropdown.tsx +++ b/packages/web/src/components/ui/dropdown.tsx @@ -2,10 +2,28 @@ * Dropdown container (controlled): clicking outside collapses it; the panel is absolutely * positioned (z-40, per the layering convention — chrome avoids stacking contexts, menus are * z-40, overlays are z-50). menuClass controls the docking direction and width. + * + * `portal` switches the panel to the shared popup strategy (same idea as OptionMenu's + * use-portal-panel): mounted on document.body with `position: fixed` against viewport + * coordinates, so **no ancestor's overflow can clip it**. Anchoring inside a scrolling + * container (the composer toolbar scrolls horizontally on phones) makes that mandatory: + * setting one axis to a non-`visible` overflow makes the other compute to `auto` too, so an + * absolutely-positioned panel — an upward one especially — is clipped away entirely. */ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import type { CSSProperties, ReactNode } from "react"; +/** Gap between the trigger and the portaled panel, and the panel's minimum distance from the viewport edge (px). */ +const PANEL_GAP = 4; +const VIEWPORT_MARGIN = 8; + +/** Portal docking: which way the panel opens and which of its edges lines up with the trigger. */ +export interface DropdownPortal { + direction: "up" | "down"; + align: "left" | "right"; +} + export function Dropdown({ button, open, @@ -14,23 +32,38 @@ export function Dropdown({ menuClass, menuStyle, className, + portal, }: { button: ReactNode; open: boolean; setOpen: (v: boolean) => void; children: ReactNode; - /** Panel positioning and size (default: downward, left-aligned, w-64). */ + /** Panel positioning and size (default: downward, left-aligned, w-64). With `portal`, only the size classes apply — placement comes from the measured trigger rect. */ menuClass?: string; /** Inline overrides for the panel, for values a static class cannot know (e.g. a max-width measured from the trigger's viewport offset). */ menuStyle?: CSSProperties; /** Extra classes for the root container (e.g. flex-1 in a flex layout). */ className?: string; + /** Render the panel through a body portal at fixed viewport coordinates, escaping any clipping ancestor. */ + portal?: DropdownPortal; }) { const ref = useRef(null); + const panelRef = useRef(null); + /** Fixed coordinates of the portaled panel, measured from the trigger when it opens. */ + const [pos, setPos] = useState<{ + top?: number; + bottom?: number; + left: number; + maxHeight: number; + } | null>(null); + useEffect(() => { if (!open) return; const onClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + const target = e.target as Node; + if (ref.current?.contains(target)) return; + if (panelRef.current?.contains(target)) return; + setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); @@ -42,19 +75,110 @@ export function Dropdown({ window.removeEventListener("keydown", onKey); }; }, [open, setOpen]); + + // Portal mode: place the panel against the trigger's viewport rect, clamped to stay fully + // on-screen. Measured after paint so the panel's own (class-driven) size is known — the + // width decides how far the left edge must be pulled back on a phone, and the height decides + // whether the requested direction has room. + const place = useCallback(() => { + if (!open || !portal || !ref.current) { + setPos(null); + return; + } + const rect = ref.current.getBoundingClientRect(); + // offsetWidth/offsetHeight, not the bounding rect: the panel plays a pop-in scale + // animation, and a mid-animation rect reports a smaller box — which would under-clamp the + // left edge and let the settled panel overrun the viewport. The offset box is untransformed. + const panel = panelRef.current; + const panelWidth = panel?.offsetWidth ?? 0; + const panelHeight = panel?.offsetHeight ?? 0; + const wanted = portal.align === "right" ? rect.right - panelWidth : rect.left; + const left = Math.max( + VIEWPORT_MARGIN, + Math.min(wanted, window.innerWidth - panelWidth - VIEWPORT_MARGIN), + ); + // Flip when the requested side can't hold the panel and the other side has more room, then + // cap the height to whatever that side actually offers: the panel scrolls internally rather + // than running off the top or bottom of the screen, so every row stays reachable. + const roomBelow = window.innerHeight - rect.bottom - PANEL_GAP - VIEWPORT_MARGIN; + const roomAbove = rect.top - PANEL_GAP - VIEWPORT_MARGIN; + const wantUp = portal.direction === "up"; + const fits = wantUp ? panelHeight <= roomAbove : panelHeight <= roomBelow; + const up = fits ? wantUp : roomAbove > roomBelow; + setPos({ + left, + maxHeight: Math.max(0, up ? roomAbove : roomBelow), + ...(up + ? { bottom: window.innerHeight - rect.top + PANEL_GAP } + : { top: rect.bottom + PANEL_GAP }), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, portal?.direction, portal?.align, children]); + + useLayoutEffect(place, [place]); + + // Fixed coordinates go stale when anything scrolls or the viewport changes — **re-place** + // rather than closing: the trigger lives in a horizontally scrolling toolbar on phones, and + // tapping a partially visible control scrolls it into view, which would otherwise dismiss + // the panel the same gesture just opened. The panel's own inner scroll is exempt. Capture + // phase — scroll events don't bubble. + useEffect(() => { + if (!open || !portal) return; + const onScroll = (e: Event) => { + if (panelRef.current?.contains(e.target as Node)) return; + place(); + }; + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", place); + }; + }, [open, portal, place]); + + const panelClass = `anim-pop z-40 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900 ${ + portal ? "" : "max-h-[70vh]" + } ${menuClass ?? "left-0 top-full mt-1 w-64 max-w-[calc(100vw-2rem)] origin-top-left"}`; + return (
{button} - {open && ( -
- {children} -
- )} + {open && + (portal ? ( + createPortal( +
+ {children} +
, + document.body, + ) + ) : ( +
+ {children} +
+ ))}
); } diff --git a/packages/web/src/features/chat/agent-mentions.ts b/packages/web/src/features/chat/agent-mentions.ts index 29447d5..a75a77b 100644 --- a/packages/web/src/features/chat/agent-mentions.ts +++ b/packages/web/src/features/chat/agent-mentions.ts @@ -8,13 +8,32 @@ * caret, driving the agent-picker popup; * - `filterAgents`: filters candidates by prefix (agentId or display name, case-insensitive); * - `splitLeadingMention`: on send, parses a leading `@`, splitting off the target - * agent from the remaining text; - * - `handoffMessage`: generates the first message for the @-mentioned agent's **new - * conversation** — an origin note (`` block) carrying the source agent / - * Session / Workspace, followed by the user's text as a subsequent text part). + * agent from the remaining text. + * + * The origin **marker blocks** these flows produce and render — `[handoff_from]`, + * `[scheduled_task]`, `[model_switch_from]` — are defined in core's marker module + * (`@prismshadow/penguin-core/markers`) alongside every other message marker, and are + * re-exported below under this feature's existing names. */ +import { buildHandoffMessage, buildModelSwitchMessage } from "@prismshadow/penguin-core/markers"; import type { AgentSummary } from "@prismshadow/penguin-server/api"; +export { + parseHandoffMessage, + parseModelSwitchMessage, + parseScheduledMessage, +} from "@prismshadow/penguin-core/markers"; +export type { + HandoffOrigin, + ModelSwitchOrigin, + ScheduledOrigin, +} from "@prismshadow/penguin-core/markers"; + +/** First message of an @-handoff new conversation (core's `[handoff_from]` origin block). */ +export const handoffMessage = buildHandoffMessage; +/** First message of a `/model` switch new conversation (core's `[model_switch_from]` origin block). */ +export const modelSwitchMessage = buildModelSwitchMessage; + /** Id characters allowed between `@` and the caret (matches core's id convention: letters, digits, underscore, hyphen). */ const ID_PREFIX = /^[\w-]*$/; @@ -73,174 +92,3 @@ export function splitLeadingMention( if (!agent) return null; return { agent, rest: m[2]!.trimStart() }; } - -/** Origin info for an @-handoff new conversation: source agent is always present; source Session is omitted while it's still a draft (not yet created). */ -export interface HandoffOrigin { - agentId: string; - agentName?: string; - sessionId?: string; - sessionTitle?: string; - workspace?: string; -} - -/** - * First message of an @-handoff new conversation (in English): the `` block - * states that this conversation was opened by an @ mention and carries the source agent / - * Session / Workspace, so the @-mentioned agent knows its origin (e.g. defaulting to the - * source agent as its working target, or reaching source files via the Workspace path); - * the parenthetical label is omitted when the display name/title equals the id or is - * absent. When rendering the message stream, `parseHandoffMessage` collapses this into a - * one-line handoff notice — the raw text isn't shown (the model still sees it as usual). - */ -export function handoffMessage(origin: HandoffOrigin): string { - const name = - origin.agentName && origin.agentName !== origin.agentId ? ` (${origin.agentName})` : ""; - const lines = [`agent: ${origin.agentId}${name}`]; - if (origin.sessionId) { - const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : ""; - lines.push(`session: ${origin.sessionId}${title}`); - } - if (origin.workspace) lines.push(`workspace: ${origin.workspace}`); - return [ - "", - "This conversation was opened by @-mentioning you from another conversation; its origin is listed below and the user's message, if any, follows. When the request refers to an agent, session, or files without naming them, it means this origin.", - ...lines, - "", - ].join("\n"); -} - -/** - * Inverse parse of `handoffMessage` (lets the message stream collapse the origin block into - * a handoff notice): returns origin info when the whole message is strictly one - * `` block, otherwise returns null (a normal user message renders as-is). - * Field lines are parsed as `key: id (label)`; non-field lines such as the explanation - * sentence are ignored. - */ -export function parseHandoffMessage(text: string): HandoffOrigin | null { - const block = /^\n([\s\S]*)\n<\/handoff_from>$/.exec(text.trim()); - if (!block) return null; - const origin: HandoffOrigin = { agentId: "" }; - for (const line of block[1]!.split("\n")) { - const kv = /^(agent|session|workspace): (.+)$/.exec(line); - if (!kv) continue; - const key = kv[1]!; - const value = kv[2]!; - if (key === "workspace") { - origin.workspace = value; - continue; - } - const labeled = /^([\w-]+) \((.*)\)$/.exec(value); - const id = labeled ? labeled[1]! : value; - const label = labeled?.[2]; - if (key === "agent") { - origin.agentId = id; - if (label !== undefined) origin.agentName = label; - } else { - origin.sessionId = id; - if (label !== undefined) origin.sessionTitle = label; - } - } - return origin.agentId ? origin : null; -} - -/** - * Origin info for a `/model` switch new conversation (modeled on HandoffOrigin): the source - * Session, the absolute path of its latest Trace file (the model reads it for the earlier - * history — nothing is injected into the new context), the shared Workspace, and the - * previous model's paired reference. - */ -export interface ModelSwitchOrigin { - sessionId: string; - sessionTitle?: string; - /** Absolute path of the source session's latest Trace file (JSONL of OmniMessage envelopes). */ - tracePath?: string; - workspace?: string; - /** The source session's model reference pair (never concatenated — two separate fields). */ - prevProvider?: string; - prevModelId?: string; -} - -/** - * First message of a `/model` switch new conversation (in English, mirroring - * `handoffMessage`): the `` block states that this conversation continues - * an earlier one on a different model and carries the source Session / Trace path / - * Workspace / previous model pair. The earlier history is deliberately NOT injected — some - * models require thinking payloads and provider `fidelity` byte-for-byte when history is - * replayed, which cannot cross models — so the model reads the Trace file itself when it - * needs the context. When rendering, `parseModelSwitchMessage` collapses this into a - * one-line switch notice (the raw text isn't shown; the model still sees it as usual). - */ -export function modelSwitchMessage(origin: ModelSwitchOrigin): string { - const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : ""; - const lines = [`session: ${origin.sessionId}${title}`]; - if (origin.tracePath) lines.push(`trace: ${origin.tracePath}`); - if (origin.workspace) lines.push(`workspace: ${origin.workspace}`); - if (origin.prevModelId) lines.push(`previous_model: ${origin.prevModelId}`); - if (origin.prevProvider) lines.push(`previous_provider: ${origin.prevProvider}`); - return [ - "", - "The user switched models: this conversation continues an earlier conversation, now on a different model. Its origin is listed below and the user's message, if any, follows. The earlier history is NOT in your context — when you need it, read the trace file at the path below (JSONL, one message envelope per line: user/assistant text, tool calls and results).", - ...lines, - "", - ].join("\n"); -} - -/** - * Inverse parse of `modelSwitchMessage` (lets the message stream collapse the origin block - * into a switch notice): returns origin info when the whole message is strictly one - * `` block, otherwise null (a normal user message renders as-is). - * Field lines parse as `key: value`; the session line's `(title)` label is split off; - * non-field lines such as the explanation sentence are ignored. - */ -export function parseModelSwitchMessage(text: string): ModelSwitchOrigin | null { - const block = /^\n([\s\S]*)\n<\/model_switch_from>$/.exec(text.trim()); - if (!block) return null; - const origin: ModelSwitchOrigin = { sessionId: "" }; - for (const line of block[1]!.split("\n")) { - const kv = /^(session|trace|workspace|previous_model|previous_provider): (.+)$/.exec(line); - if (!kv) continue; - const key = kv[1]!; - const value = kv[2]!; - if (key === "session") { - const labeled = /^([\w-]+) \((.*)\)$/.exec(value); - origin.sessionId = labeled ? labeled[1]! : value; - if (labeled?.[2] !== undefined) origin.sessionTitle = labeled[2]; - } else if (key === "trace") origin.tracePath = value; - else if (key === "workspace") origin.workspace = value; - else if (key === "previous_model") origin.prevModelId = value; - else origin.prevProvider = value; - } - return origin.sessionId ? origin : null; -} - -/** Origin info for a scheduled-task trigger (the server's scheduledMessage `` block). */ -export interface ScheduledOrigin { - /** Task name (filename minus .toml). */ - name: string; - /** Trigger timestamp (ISO 8601); empty string when absent from the block. */ - firedAt: string; -} - -/** - * Inverse parse of the server's scheduledMessage (lets the message stream collapse the - * origin block into a scheduled-task notice): returns origin info and the remaining text - * when the message **starts with** a `` block, otherwise returns null. - * Unlike handoff, the block is followed by the task's Prompt body, which must be returned - * alongside it for normal rendering (the raw block isn't shown; the Trace page shows it - * as-is). - */ -export function parseScheduledMessage( - text: string, -): { origin: ScheduledOrigin; rest: string } | null { - const m = /^\n([\s\S]*?)\n<\/scheduled_task>/.exec(text); - if (!m) return null; - const origin: ScheduledOrigin = { name: "", firedAt: "" }; - for (const line of m[1]!.split("\n")) { - const kv = /^(schedule|fired_at): (.+)$/.exec(line); - if (!kv) continue; - if (kv[1] === "schedule") origin.name = kv[2]!; - else origin.firedAt = kv[2]!; - } - if (!origin.name) return null; - return { origin, rest: text.slice(m[0].length).replace(/^\n+/, "") }; -} diff --git a/packages/web/src/features/chat/chat-input.tsx b/packages/web/src/features/chat/chat-input.tsx index d95a1d4..4344156 100644 --- a/packages/web/src/features/chat/chat-input.tsx +++ b/packages/web/src/features/chat/chat-input.tsx @@ -31,13 +31,20 @@ * clicking a row toggles its selection without closing the menu; the button = book icon + label + * selected-count badge, disabled while running/compacting). With skills selected, sending with an * empty text body is allowed — the sent text automatically falls back to S.chat.skillsAutoMessage. - * When sent, the text body wraps in a `` block (the handoff's rest-of-message body is + * When sent, the text body wraps in a `[use_skills]` block (the handoff's rest-of-message body is * wrapped the same way); the selection clears once sending succeeds. Quick-invoke pre-selects via * initialSkills (read once on mount; once the installed list is ready, names not in that list are * pruned); the slash menu also lists installed skills, and pressing Enter on `/` * selects it. - * Switches to a "Stop" button while a Task is running; disabled with a reason shown while - * compacting. + * While a Task is running the input stays enabled and the toolbar keeps ONE action button: + * an empty composer shows Stop (abort), and typing turns that same button into Send, which + * follows the remembered mid-run send mode — steer (delivered between turns as a + * [user_steering] user message) or queue-as-follow-up — chosen from the toolbar's + * More-settings popover (available in draft state too, persisted in localStorage); sending is + * disabled with a reason shown while compacting. + * The toolbar is a single left/right row: settings controls left (scrolling horizontally when + * the card is too narrow), status + model + the action button right (never shrinking), so a + * phone viewport never pushes the action button off-screen. * Renders only the card body itself: outer positioning such as bottom-docking or vertical * centering is decided by the page. */ @@ -120,12 +127,11 @@ function ApprovalModeSelect({ void; + direction?: "up" | "down"; +}) { + const [open, setOpen] = useState(false); + // Compact pill (small-control sizing, sized to its label): the explanation is hover-only + // via title, per the toolbar's "full meaning on hover" convention. + const modeButton = (mode: SteerMode, label: string, hint: string) => ( + + ); + return ( + setOpen((v) => !v)} + className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200" + > + + + } + > + {/* Setting row: label + compact control (descriptions hover-only). New settings append as further rows. */} +
+

+ {S.chat.steerModeLabel} +

+
+ {modeButton("steer", S.chat.steerModeSteer, S.chat.steerModeSteerHint)} + {modeButton("followup", S.chat.steerModeFollowUp, S.chat.steerModeFollowUpHint)} +
+
+
+ ); +} + /** * Multi-select skills dropdown (bottom toolbar, after approval mode): styled like the model * selector — button = book icon + "Skills" label + selected-count badge (no badge at 0; when the @@ -557,18 +650,10 @@ function SkillSelect({ - {unknown ? "—" : humanizeTokens(now)}/{humanizeTokens(max)} + {/* The ring alone carries the meaning on phones: the numbers hide below @md (the title + still shows the exact usage), keeping the right-hand control group inside a 320px + viewport in the running state. */} + + {unknown ? "—" : humanizeTokens(now)}/{humanizeTokens(max)} + ); } @@ -734,6 +824,10 @@ function ContextGauge({ export function ChatInput({ status, onSend, + onSteer, + steeringDeliveredCount, + onQueueFollowUp, + queuedFollowUps = 0, onStop, onCompact, modelRef, @@ -766,6 +860,29 @@ export function ChatInput({ status: SessionStatus; /** Returns whether it succeeded: on failure the input draft is kept (not cleared). */ onSend: (input: TaskInputPart[]) => Promise; + /** + * Mid-run steering (session state only): while a Task is running, Enter/send queues the + * trimmed text for the running agent — it is delivered between turns as a standalone + * `[user_steering]` user message. `"queued"` clears the text and shows the queued hint; + * `"not_running"` (409 race with completion) makes the input fall back to its full normal + * send path; `"failed"` keeps the draft. When absent (draft state), the input stays + * send-disabled while running, as before. + */ + onSteer?: (text: string) => Promise<"queued" | "not_running" | "failed">; + /** + * Count of steering messages already visible in the message stream: the queued hint stays + * up until this increases past its value at queue time (i.e. the message was delivered). + */ + steeringDeliveredCount?: number; + /** + * Follow-up queue (session state only): posts the full input with `queueIfBusy` — a busy + * session holds it server-side and auto-sends it as an ordinary next task once the current + * run finishes. Offered as the "follow-up" choice of the mid-run mode switch; when absent, + * only steering is offered while running. + */ + onQueueFollowUp?: (input: TaskInputPart[]) => Promise; + /** Server-reported queued follow-up count (from task_state): renders the "N queued" hint until they auto-send. */ + queuedFollowUps?: number; /** * Used instead of onSend when an @ target is present (chip or a leading @ typed manually): * opens a new chat for the target agent (the text body carries no @ marker, and the current @@ -898,13 +1015,60 @@ export function ChatInput({ const running = status === "running"; const compacting = status === "compacting"; // Sending is also allowed with only an @ target (chip) or skills selected and no text: a handoff's - // first message may be just a source block; with skills and empty text, the sent + // first message may be just a [handoff_from] source block; with skills and empty text, the sent // text automatically falls back to S.chat.skillsAutoMessage (see send). const canSend = !running && !compacting && !busy && (text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0); + // Mid-run steering: while running, Enter/send queues plain text for the running agent + // (delivered between turns as a [user_steering] user message). Text only — images / skills / + // @ target stay in the draft for a later normal send (an @ target also blocks steering: a + // leading mention means a handoff, not a message to this agent). + const canSteer = + running && !busy && onSteer !== undefined && target === null && text.trim().length > 0; + // Mid-run send mode (owner directive): the user chooses between "steer" (delivered + // mid-run as a [user_steering] input) and "follow-up" (held server-side and auto-sent as + // an ordinary next task once this run finishes). Set from the More-settings popover on + // the toolbar — available in draft state and active sessions alike — and **remembered** + // across sessions/reloads (localStorage, see STEER_MODE_KEY); the running-state send + // simply follows the remembered mode. + const [steerMode, setSteerModeState] = useState(initialSteerMode); + const setSteerMode = (mode: SteerMode): void => { + setSteerModeState(mode); + localStorage.setItem(STEER_MODE_KEY, mode); + }; + const followUpMode = steerMode === "followup" && onQueueFollowUp !== undefined; + // A follow-up is a full normal message: the whole draft (text / images / skills / handoff) + // is eligible, same content rule as canSend. + const canFollowUp = + running && + !busy && + followUpMode && + (text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0); + // The single action button's mode: while running, an **empty** composer means Stop + // (abort); as soon as there is something to send it becomes the send button (steer or + // follow-up per the remembered mode). Idle/compacting is always send. + const canMidRunSend = followUpMode ? canFollowUp : canSteer; + const midRunSendLabel = followUpMode ? S.chat.followUpSend : S.chat.steerSend; + const stopAction = + running && + text.trim().length === 0 && + images.length === 0 && + target === null && + selectedSkills.length === 0; + // Queued hint: shown after a successful steer until the message shows up in the stream + // (steeringDeliveredCount increases past the baseline captured at queue time) or the run + // stops being observable (task no longer running). + const [steerPending, setSteerPending] = useState(false); + const steerBaseline = useRef(0); + useEffect(() => { + if (!steerPending) return; + if (!running || (steeringDeliveredCount ?? 0) > steerBaseline.current) { + setSteerPending(false); + } + }, [steerPending, running, steeringDeliveredCount]); /** Toggle a skill on/off (shared by dropdown option clicks and the slash skill command); the change callback lets the parent write it into the draft. */ const toggleSkill = useCallback( @@ -1153,8 +1317,16 @@ export function ChatInput({ autoGrow(); }; - const send = async () => { - if (!canSend) return; + /** + * The full normal send path (task / handoff), also the follow-up queue path and the + * fallback target when a steer hits the completion race: assembles the [use_skills] + * block, images and @ handoff from the whole draft; `post` decides where a non-handoff + * message goes (default: onSend; follow-up mode: onQueueFollowUp). Deliberately not + * gated on `running` — the caller decides (send() gates the normal path; the steering + * fallback calls this directly after the server said 409 not_running, when the local + * `status` may still lag behind). + */ + const sendNormal = async (post: (input: TaskInputPart[]) => Promise = onSend) => { const t = text.trim(); // @ target = the chip (selected via menu), or a leading `@` typed/pasted manually // (an @ in the middle of the text is plain text); with a target present, this becomes a @@ -1165,14 +1337,14 @@ export function ChatInput({ const rest = lead ? lead.rest : t; const bodyText = selectedSkills.length > 0 && rest === "" ? S.chat.skillsAutoMessage(selectedSkills) : rest; - // With non-empty selected skills: the text body is replaced with a block + the text (the handoff branch wraps rest the same way). + // With non-empty selected skills: the text body is replaced with a [use_skills] block + the text (the handoff branch wraps rest the same way). const body = buildSkillsMessage(selectedSkills, bodyText); const input: TaskInputPart[] = []; if (body) input.push({ type: "text", text: body }); for (const url of images) input.push({ type: "image_url", imageUrl: url }); setBusy(true); try { - const ok = lead ? await onHandoff(lead.agent, input) : await onSend(input); + const ok = lead ? await onHandoff(lead.agent, input) : await post(input); // Only clear the draft after a successful send: on failure (network / conflict / server error) keep the user's input and images. if (ok) { setText(""); @@ -1187,6 +1359,46 @@ export function ChatInput({ } }; + const send = async () => { + if (running) { + // Follow-up branch: the whole draft goes out through the normal composition path, + // but posted with queueIfBusy — the server holds it and auto-sends once this run + // finishes (an @ handoff still opens a new chat directly: the target session isn't + // the one running). + if (followUpMode) { + if (!canFollowUp) return; + await sendNormal(onQueueFollowUp!); + return; + } + // Steering branch: queue the trimmed text for the running agent; only the text is + // sent and cleared — attached images / selected skills stay for a normal send. + if (!canSteer) return; + const steerText = text.trim(); + setBusy(true); + let res: "queued" | "not_running" | "failed" = "failed"; + try { + res = await onSteer!(steerText); + if (res === "queued") { + // Show the "queued" hint until the steering message shows up in the stream + // (steeringDeliveredCount increases) — see the effect below. + steerBaseline.current = steeringDeliveredCount ?? 0; + setSteerPending(true); + setText(""); + requestAnimationFrame(autoGrow); + } + } finally { + setBusy(false); + textareaRef.current?.focus(); + } + // Completion race (server: no Task running anymore): deliver the whole draft — images, + // skills and all — through the full normal send path instead of a text-only task. + if (res === "not_running") await sendNormal(); + return; + } + if (!canSend) return; + await sendNormal(); + }; + const onKeyDown = (e: KeyboardEvent) => { if (slashOpen) { if (e.key === "ArrowDown") { @@ -1403,6 +1615,22 @@ export function ChatInput({

)} + {/* Mid-run steering queued: lightweight hint until the steering message appears in the + stream (or the run ends). */} + {steerPending && ( +

+ {S.chat.steerQueuedIndicator} +

+ )} + + {/* Queued follow-ups (server-side, auto-sent once this run finishes): count from + task_state — survives reloads because the queue lives on the server. */} + {queuedFollowUps > 0 && ( +

+ {S.chat.followUpQueuedChip(queuedFollowUps)} +

+ )} + {/* Unified input card: the multi-line text body occupies the top area, with all controls collected onto a single bottom row that never shares a line with the text. @container: the bottom toolbar row collapses based on the **card's actual width** @@ -1496,158 +1724,186 @@ export function ChatInput({ onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)} onKeyDown={onKeyDown} onPaste={onPaste} - placeholder={narrow ? S.chat.inputPlaceholderShort : S.chat.inputPlaceholder} + placeholder={ + running && followUpMode + ? narrow + ? S.chat.followUpPlaceholderShort + : S.chat.followUpPlaceholder + : running && onSteer + ? narrow + ? S.chat.steerPlaceholderShort + : S.chat.steerPlaceholder + : narrow + ? S.chat.inputPlaceholderShort + : S.chat.inputPlaceholder + } className="block max-h-44 min-h-[60px] w-full resize-none bg-transparent px-1 py-0.5 text-base leading-6 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:placeholder:text-gray-500" /> - {/* Bottom toolbar row: attachments + approval mode + help text | context usage + Model + send */} -
-
diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index ea5bc3b..26638b4 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -408,7 +408,7 @@ export function ChatPage() { // /model switch (handoff-style, mirroring onHandoff exactly): opens a NEW session for the // SAME agent on the picked model via the normal createSession API — deliberately with the // SOURCE session's Workspace, so files the conversation refers to stay reachable — then - // posts a first task whose input starts with a source block (source + // posts a first task whose input starts with a [model_switch_from] source block (source // session id / its latest trace path / workspace / previous model pair) followed by the // user's remainder text and images. The earlier history is NOT injected into the new // context (some models require thinking payloads and provider fidelity byte-for-byte on @@ -462,7 +462,7 @@ export function ChatPage() { // @ handoff: doesn't use the current Session — creates a new chat for the @-mentioned agent // (approval mode carries over from the input area's current value; model/Workspace use the - // creation defaults). The first input = a source block (current agent / Session + // creation defaults). The first input = a [handoff_from] source block (current agent / Session // / Workspace info) + the user's input and images with the @ mention stripped; jumps to the new // chat once sent. // Returns false on failure, keeping the draft so it can be resent (deletes the empty Session that never got its first message sent). @@ -508,6 +508,46 @@ export function ChatPage() { await api.postAbort(selected.sessionId).catch(() => undefined); }, [selected]); + // Follow-up queue: post the full input with queueIfBusy — a busy session holds it + // server-side and auto-sends it as an ordinary next task once this run finishes (the + // "N queued" count arrives via task_state). Succeeds either way (queued or started + // directly in the completion race), so the input area clears the draft on true. + const onQueueFollowUp = useCallback( + async (input: TaskInputPart[]): Promise => { + if (!selected) return false; + try { + const res = await api.postTask(selected.sessionId, { input, queueIfBusy: true }); + discardSessionDraft(); + await syncHealedSessionId(selected.sessionId, res.sessionId); + return true; + } catch (e) { + toastError(apiErrorText(e, { modelId: selected.modelId })); + return false; + } + }, + [selected, discardSessionDraft, syncHealedSessionId], + ); + + // Mid-run steering: the text is queued on the server and delivered between turns as a + // standalone `[user_steering]` user message (visible once it arrives over SSE / from the + // Trace). "not_running" (409) means no Task is in progress anymore (race with completion): + // the input area then falls back to its **full** normal send path — images / skills / the + // whole draft included — rather than a text-only task. + const onSteer = useCallback( + async (text: string): Promise<"queued" | "not_running" | "failed"> => { + if (!selected) return "failed"; + try { + await api.postSteer(selected.sessionId, { text }); + return "queued"; + } catch (e) { + if (e instanceof ApiError && e.status === 409) return "not_running"; + toastError(apiErrorText(e, { modelId: selected.modelId })); + return "failed"; + } + }, + [selected], + ); + const onApprove = useCallback( async (toolCallId: string, decision: "allow" | "deny", origin: string[]) => { if (!selected) return; @@ -611,6 +651,12 @@ export function ChatPage() { i.kind === "user_steering").length} + onQueueFollowUp={onQueueFollowUp} + queuedFollowUps={stream.queuedFollowUps} onStop={onStop} onCompact={onCompact} modelRef={activeModelRef} diff --git a/packages/web/src/features/chat/draft-view.tsx b/packages/web/src/features/chat/draft-view.tsx index 7f7f04e..fe64aa0 100644 --- a/packages/web/src/features/chat/draft-view.tsx +++ b/packages/web/src/features/chat/draft-view.tsx @@ -88,7 +88,7 @@ function saveAppliedRouteKey(field: RouteStateField, key: string): void { /** * Example tasks on the draft screen, in display order (game card first, LoL music player, * then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a - * `` block — only those the selected Agent actually has installed are included, + * `[use_skills]` block — only those the selected Agent actually has installed are included, * so the block never references a skill the agent can't read. */ const EXAMPLE_TASKS: { id: "game" | "lol" | "rag"; skills: string[] }[] = [ @@ -258,7 +258,7 @@ export function DraftView({ // mount render would trigger ChatInput's pruning effect and wrongly clear the // quick-invoke preselection. const [agentSkills, setAgentSkills] = useState([]); - /** Whether the skills fetch for the current Agent has settled — the example task waits for it so its `` pinning doesn't silently depend on network timing. */ + /** Whether the skills fetch for the current Agent has settled — the example task waits for it so its `[use_skills]` pinning doesn't silently depend on network timing. */ const [skillsLoaded, setSkillsLoaded] = useState(false); useEffect(() => { setAgentSkills((prev) => (prev.length > 0 ? [] : prev)); @@ -442,7 +442,7 @@ export function DraftView({ // @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the // draft's current value; model/Workspace use the creation defaults), first input = - // source block + the text and images with the @ mention stripped. + // [handoff_from] source block + the text and images with the @ mention stripped. const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null; const onHandoff = useCallback( async (target: AgentSummary, input: TaskInputPart[]): Promise => { diff --git a/packages/web/src/features/chat/handoff-banner.tsx b/packages/web/src/features/chat/handoff-banner.tsx index f634ccb..eeefd17 100644 --- a/packages/web/src/features/chat/handoff-banner.tsx +++ b/packages/web/src/features/chat/handoff-banner.tsx @@ -1,8 +1,8 @@ /** * Provenance banners for conversations opened from another conversation — each collapses a * machine-inserted source block (the raw text is never shown; the model still sees it): - * - `HandoffBanner` (``, @ delegation): "Handed off from 's chat"; - * - `ModelSwitchBanner` (``, the /model command): "switched model — + * - `HandoffBanner` (`[handoff_from]`, @ delegation): "Handed off from 's chat"; + * - `ModelSwitchBanner` (`[model_switch_from]`, the /model command): "switched model — * continued from the earlier conversation". * When there's a source Session, the whole line is clickable and jumps back to it (the * source Session's title goes into the title hover tooltip, taking no space in the body). @@ -43,7 +43,7 @@ export function HandoffBanner({ origin }: { origin: HandoffOrigin }) { } /** - * Notice for a conversation opened by the `/model` switch (`` first + * Notice for a conversation opened by the `/model` switch (`[model_switch_from]` first * message): a single line naming the previous model, clickable to jump back to the source * session — the same interaction as the handoff banner's back-link. */ diff --git a/packages/web/src/features/chat/message-item.tsx b/packages/web/src/features/chat/message-item.tsx index 8f94961..dee427d 100644 --- a/packages/web/src/features/chat/message-item.tsx +++ b/packages/web/src/features/chat/message-item.tsx @@ -34,6 +34,10 @@ import type { StreamRenderContext } from "./message-stream"; /** Duration the "Copied" tooltip stays visible (milliseconds). */ const COPIED_MS = 1200; +/** User glyph (24×24 line path) for the mid-run steering chip. */ +const USER_STEERING_ICON = + "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0"; + /** * Message footer: timestamp + copy. **Invisible but takes up space by default** (`opacity-0` * rather than `hidden`) — it surfaces on hovering the message list, and because the space is @@ -143,6 +147,32 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo ); } + case "user_steering": + // Mid-run steering ([user_steering]-wrapped user text delivered between turns): a + // compact right-aligned user-styled chip inside the running Task's flow — visually + // lighter than a full prompt bubble, since it doesn't start a new Task (the Trace page + // still shows the raw marker text as-is). + return ( +
+
+ +

+ + {S.chat.userSteering} + + {item.text} +

+
+ +
+ ); case "user_image": return (
diff --git a/packages/web/src/features/chat/scheduled-banner.tsx b/packages/web/src/features/chat/scheduled-banner.tsx index 5a0eaf0..14be729 100644 --- a/packages/web/src/features/chat/scheduled-banner.tsx +++ b/packages/web/src/features/chat/scheduled-banner.tsx @@ -1,5 +1,5 @@ /** - * Origin hint for a scheduled-task-triggered message: the origin block () is + * Origin hint for a scheduled-task-triggered message: the origin block ([scheduled_task]) is * not rendered verbatim on screen; it collapses into one line reading "Triggered by scheduled * task ''" plus a localized trigger timestamp (a static display with no navigation — task * management lives on the Agent settings page's "Scheduled Tasks" tab). The task prompt body diff --git a/packages/web/src/features/chat/skill-use.ts b/packages/web/src/features/chat/skill-use.ts index 955d41f..8cf1873 100644 --- a/packages/web/src/features/chat/skill-use.ts +++ b/packages/web/src/features/chat/skill-use.ts @@ -1,52 +1,21 @@ /** - * Skill invocation for the chat input area (pure logic, shared by chat-input / message-item / - * the Skill library page, and unit tests). - * The `` block is a globally agreed-upon format (shared by frontend, backend, and the - * core prompt template): + * Skill-invocation helpers for the chat input area (pure logic, shared by chat-input / + * message-item / the Skill library page, and unit tests). * - * - * skills: name1, name2 - * - * (blank line) body text… - * - * - `buildSkillsMessage`: prepends the source block to the body when selected skills are - * non-empty; returns the body unchanged for an empty list; - * - `parseSkillsMessage`: only recognizes a block at **the start of the message**, returning the - * skill names and the remaining body (the message stream collapses the raw block into a - * "Skill used" banner and renders the body normally; the Trace page shows it as-is); - * - `localizedText` / `skillSlashItems`: pure assembly of UI-language text lookup and slash - * skill command items. + * The `[use_skills]` marker block itself is **not** defined here: it is a globally agreed + * format shared by the frontend, the backend and the core prompt template, so its producer + * and parser live in core's marker module (`@prismshadow/penguin-core/markers`) and are + * re-exported below for this feature's existing importers. What stays local is the UI-only + * part: icon path, UI-language text selection, dropdown filtering and slash command items. */ import type { SkillMetadataItem } from "@prismshadow/penguin-server/api"; +export { buildSkillsMessage, parseSkillsMessage } from "@prismshadow/penguin-core/markers"; + /** Book icon (24×24 line path): shared across skill-related UI (nav items are inlined separately in sidebar / app-layout). */ export const BOOK_ICON = "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"; -/** Generates a message body with a `` block: an empty list omits the block; when there's no body text, only the block is returned (no trailing blank line). */ -export function buildSkillsMessage(names: string[], text: string): string { - if (names.length === 0) return text; - const block = `\nskills: ${names.join(", ")}\n`; - return text ? `${block}\n\n${text}` : block; -} - -/** - * Reverse parse of `buildSkillsMessage`: when the message **starts with** a `` - * block, returns the skill names and the remaining body; otherwise returns null (a block - * appearing mid-body is treated as plain text and not parsed). The `skills:` line is split by - * comma and whitespace-trimmed; an empty list is treated as not a source block. - */ -export function parseSkillsMessage(text: string): { skills: string[]; rest: string } | null { - const m = /^\nskills: ([^\n]+)\n<\/use_skills>/.exec(text); - if (!m) return null; - const skills = m[1]! - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - if (skills.length === 0) return null; - return { skills, rest: text.slice(m[0].length).replace(/^\n+/, "") }; -} - /** * Picks copy based on the UI language: uses the Chinese value when locale is zh and one is * provided, otherwise falls back to English. Shared by the skill library page's group diff --git a/packages/web/src/features/chat/skills-banner.tsx b/packages/web/src/features/chat/skills-banner.tsx index b174d5c..6fbac73 100644 --- a/packages/web/src/features/chat/skills-banner.tsx +++ b/packages/web/src/features/chat/skills-banner.tsx @@ -1,5 +1,5 @@ /** - * Source notice for a skill invocation: the `` block at the start of a message isn't + * Source notice for a skill invocation: the `[use_skills]` block at the start of a message isn't * shown verbatim, it's collapsed into a single line reading "Using skills: " (book icon + * static text, no navigation — skill management lives on the skill library page); the body text * after the block is rendered as usual by the caller. diff --git a/packages/web/src/features/chat/use-session-stream.ts b/packages/web/src/features/chat/use-session-stream.ts index f886952..fb9b0f8 100644 --- a/packages/web/src/features/chat/use-session-stream.ts +++ b/packages/web/src/features/chat/use-session-stream.ts @@ -36,6 +36,8 @@ export interface SessionStreamState { /** True until history finishes loading. */ loading: boolean; taskState: SessionStatus; + /** Queued follow-up count from the stream's task_state events (auto-sent once the session is idle). */ + queuedFollowUps: number; /** approvalKey(origin, toolCallId) → pending approval. */ pendingApprovals: ReadonlyMap; /** Recorded when this client clicks an approval decision (marks it as "manual"). */ @@ -61,6 +63,7 @@ export function useSessionStream( const [version, setVersion] = useState(0); const [loading, setLoading] = useState(true); const [taskState, setTaskState] = useState(initialStatus); + const [queuedFollowUps, setQueuedFollowUps] = useState(0); const [error, setError] = useState(null); const [pendingTick, setPendingTick] = useState(0); const onTitleRef = useRef(onSessionTitle); @@ -111,6 +114,7 @@ export function useSessionStream( controllerRef.current?.dispose(); controllerRef.current = null; setTaskState("idle"); + setQueuedFollowUps(0); setLoading(false); setError(null); setPendingTick((t) => t + 1); @@ -123,11 +127,13 @@ export function useSessionStream( // First-frame placeholder: the task_state snapshot from the stream (pushed on subscribe) // subsequently overrides it as the authoritative state. setTaskState(initialStatus); + setQueuedFollowUps(0); setPendingTick((t) => t + 1); const controller = createStreamController({ loadMessages: async () => (await getMessages(sessionId)).messages, onTaskState: setTaskState, + onQueuedFollowUps: setQueuedFollowUps, onLoading: setLoading, onError: setError, onModelChange: bump, @@ -187,6 +193,7 @@ export function useSessionStream( version, loading, taskState, + queuedFollowUps, pendingApprovals: controllerRef.current?.pendingApprovals ?? EMPTY_PENDING, markLocalDecision, resolveApproval, diff --git a/packages/web/src/lib/omni/stream-controller.ts b/packages/web/src/lib/omni/stream-controller.ts index 55cdeed..a28fc6e 100644 --- a/packages/web/src/lib/omni/stream-controller.ts +++ b/packages/web/src/lib/omni/stream-controller.ts @@ -53,6 +53,8 @@ export interface StreamControllerDeps { loadMessages: () => Promise; /** Authoritative running state from the stream (covers both the subscription snapshot and transition events). */ onTaskState: (state: SessionStatus) => void; + /** Queued follow-up count carried on task_state events (absent on old servers -> 0). */ + onQueuedFollowUps?: (count: number) => void; onLoading: (loading: boolean) => void; /** History load failure message (null = clear). */ onError: (message: string | null) => void; @@ -150,6 +152,7 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro // server sends the current snapshot as soon as it subscribes; the list's snapshot is only a first-frame placeholder). streamStatus = ev.state; deps.onTaskState(ev.state); + deps.onQueuedFollowUps?.(ev.queued ?? 0); if (ev.state === "idle") { // Task ended (or the snapshot confirms idle): finalize the current Task's stats; pending approvals have already converged server-side. notifyTaskIdle(model, now()); @@ -284,6 +287,7 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro if (ev.type === "task_state") { streamStatus = ev.state; deps.onTaskState(ev.state); + deps.onQueuedFollowUps?.(ev.queued ?? 0); } buffer.push({ kind: "server", ev }); return; diff --git a/packages/web/src/lib/omni/stream-model.ts b/packages/web/src/lib/omni/stream-model.ts index b7b25bc..2d1b409 100644 --- a/packages/web/src/lib/omni/stream-model.ts +++ b/packages/web/src/lib/omni/stream-model.ts @@ -32,7 +32,7 @@ * compaction_begin↔end range (the compaction prompt and summary output) * are never rendered and never counted toward Task segmentation — aligned * with the live stream (which only pushes the event pair + token_usage); - * user text prefixed with `` is a compaction-summary + * user text prefixed with `[context_summary]` is a compaction-summary * injection, treated as internal input (not rendered, doesn't start a new Task). * - Task segmentation: a complete text/image message on the main * session's user side starts a new Task; a Task ends when the live @@ -46,7 +46,11 @@ * message hits the dedup check, discardFragmentFor also discards the corresponding in-flight fragment. * Docs: /docs/omni-message § "The streaming discipline". */ -import { isEventMessage, isPartialPayload } from "@prismshadow/penguin-core/omnimessage"; +import { + isEventMessage, + isPartialPayload, + parseUserSteeringText, +} from "@prismshadow/penguin-core/omnimessage"; import type { ApprovalDecision, CompactionMode, @@ -86,6 +90,19 @@ export interface UserTextItem { atMs?: number; } +/** + * Mid-run steering: a `[user_steering]`-wrapped user text delivered between turns + * (see core `Session.steer`). Rendered as a compact user-styled chip **inside** the running + * Task's flow — it never starts a new Task (`text` is the inner message, marker stripped). + */ +export interface UserSteeringItem { + kind: "user_steering"; + id: number; + text: string; + /** Message timestamp (milliseconds): shown on footer hover. */ + atMs?: number; +} + export interface UserImageItem { kind: "user_image"; id: number; @@ -223,6 +240,7 @@ export interface TaskStatsItem { export type ChatItem = | UserTextItem + | UserSteeringItem | UserImageItem | AssistantTextItem | ThinkingItem @@ -303,7 +321,7 @@ export interface StreamModel { * the round's duration — which is correct, since compaction did occupy this round's wall-clock time; * - Compaction **after the round ends** (finalization's automatic * compaction / manual /compact), the next round's injected - * ``, and the session_meta rewritten after a file + * `[context_summary]`, and the session_meta rewritten after a file * rotation all come **after** it, and are naturally excluded from the round. * So no compaction wall-clock addition/subtraction is needed at all — * just take the span directly (history rebuild and live share the same @@ -704,12 +722,30 @@ function handleComplete( switch (p.type) { case "text": { if (p.role === "user") { - // Compaction-summary injection (`` prefix, an - // internal input in the new context file): not rendered as a user bubble, doesn't start a new Task. - if (p.text.startsWith("")) { + // Compaction-summary injection (`[context_summary]` prefix, an + // internal input in the new context file): not rendered as a user bubble, doesn't + // start a new Task. The old `` prefix is still recognized — old + // Traces containing it are re-rendered through this reducer. + if (p.text.startsWith("[context_summary]") || p.text.startsWith("")) { touchTask(model, timestamp); return; } + // Mid-run steering (`[user_steering]`-wrapped user text, delivered between turns): + // stays inside the running Task — it must NOT start a new Task (same exclusion idea + // as [context_summary]) — but unlike the summary it IS rendered, as a compact + // user-styled steering chip in-flow. + const steering = parseUserSteeringText(p.text); + if (steering !== null) { + touchTask(model, timestamp); + const steerMs = tsOf(timestamp); + model.items.push({ + kind: "user_steering", + id: nextId(model), + text: steering, + ...(steerMs !== undefined ? { atMs: steerMs } : {}), + }); + return; + } // A complete text message on the main session's user side: starts a new Task. startTask(model, timestamp, nowMs); const atMs = tsOf(timestamp); diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 569df62..545dcef 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -606,6 +606,29 @@ When done, open index.html in a browser and self-test once.`, jumpToLatest: "Jump to latest", inputPlaceholder: "Type a message. Enter to send, Shift+Enter for newline, paste images", inputPlaceholderShort: "Type a message…", + /** Placeholder while a Task is running (mid-run steering): the message is delivered between turns with the next request. */ + steerPlaceholder: "Message the running agent — delivered with the next turn", + steerPlaceholderShort: "Message the running agent…", + steerSend: "Send to the running agent", + /** Queued hint shown after a successful steer, until the steering message appears in the stream. */ + steerQueuedIndicator: "Steering queued — delivered with the next turn", + /** Label of the [user_steering] chip (a mid-run user message delivered between turns). */ + userSteering: "User steering", + /** More-settings popover on the input toolbar (extensible setting rows; holds the mid-run send mode). */ + moreSettings: "More settings", + /** Mid-run send-mode setting: steer (delivered mid-run) vs follow-up (queued until the run ends). */ + steerModeLabel: "Mid-run send mode", + steerModeSteer: "Steer", + steerModeSteerHint: "Steer now: delivered to the running agent with the next turn", + steerModeFollowUp: "Queue", + steerModeFollowUpHint: + "Queue a follow-up: sent automatically as a new message when this run finishes", + followUpPlaceholder: "Queue as the next message — sent automatically when this run finishes", + followUpPlaceholderShort: "Queue as the next message…", + followUpSend: "Queue as the next message", + /** Server-side queued follow-up count (auto-sent once the current run finishes). */ + followUpQueuedChip: (n: number) => + `${n} follow-up ${n === 1 ? "message" : "messages"} queued — sent when this run finishes`, send: "Send", stop: "Stop", compact: "Compact context", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 1592515..9af8b36 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -592,6 +592,27 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题( `${n} 条跟进消息已排队,本轮结束后自动发送`, send: "发送", stop: "停止", compact: "压缩上下文", @@ -656,7 +677,7 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题( before sending). */ + /** Auto-generated invocation text when skills are selected and the body is empty (wrapped in [use_skills] before sending). */ skillsAutoMessage: (names: string[]): string => `使用 ${names.join("、")} 技能`, handoffFrom: (agent: string) => `由 ${agent} 的对话交接而来`, handoffBack: (title?: string) => (title ? `回到原对话:${title}` : "回到原对话"), diff --git a/packages/web/test/agent-mentions.test.ts b/packages/web/test/agent-mentions.test.ts index 1d4173c..d844a8d 100644 --- a/packages/web/test/agent-mentions.test.ts +++ b/packages/web/test/agent-mentions.test.ts @@ -1,9 +1,8 @@ /** * agent-mentions.ts unit tests: @ mention matching (cursor prefix / boundary * rules), candidate filtering, send-time parsing of a leading @ mention, - * generation of the first new-conversation origin block, - * origin block parsing, and the /model switch's - * origin block round-trip. + * and the re-export wiring for the origin marker blocks (their own semantics — both forms, + * anchoring, legacy compat — are covered by packages/core/test/markers.test.ts). */ import { describe, expect, it } from "vitest"; import type { AgentSummary } from "@prismshadow/penguin-server/api"; @@ -116,136 +115,27 @@ describe("splitLeadingMention (send-time parsing of a leading @)", () => { }); }); -describe("handoffMessage (the first new-conversation origin block)", () => { - it("full origin: agent display name and Session title as parentheticals, Workspace on its own line", () => { - const text = handoffMessage({ +describe("origin marker blocks are re-exported from core", () => { + it("handoff / model-switch producers emit the square form and round-trip through the feature module", () => { + const handoff = handoffMessage({ agentId: "default_agent", workspace: "/data/ws" }); + expect(handoff.startsWith("[handoff_from]\n")).toBe(true); + expect(parseHandoffMessage(handoff)).toEqual({ agentId: "default_agent", - agentName: "General Agent", - sessionId: "session-01ABC", - sessionTitle: "Fix the parser", workspace: "/data/ws", }); - expect(text.startsWith("\n")).toBe(true); - expect(text.endsWith("\n")).toBe(true); - expect(text).toContain("agent: default_agent (General Agent)"); - expect(text).toContain("session: session-01ABC (Fix the parser)"); - expect(text).toContain("workspace: /data/ws"); - }); - - it("draft state (no Session) carries only the source agent; the parenthetical is omitted when the display name equals the id or is absent", () => { - const text = handoffMessage({ agentId: "researcher", agentName: "researcher" }); - expect(text).toContain("agent: researcher\n"); - expect(text).not.toContain("session:"); - expect(text).not.toContain("workspace:"); - expect(handoffMessage({ agentId: "researcher" })).toContain("agent: researcher\n"); - }); - - it("the parenthetical is omitted when the Session title is absent", () => { - const text = handoffMessage({ - agentId: "default_agent", - sessionId: "session-01ABC", - workspace: "/data/ws", - }); - expect(text).toContain("session: session-01ABC\n"); - }); -}); - -describe("parseHandoffMessage (reverse-parses the origin block, driving the handoff notice rendering)", () => { - it("round-trips with handoffMessage (including a Session title containing parentheses)", () => { - const origin = { - agentId: "default_agent", - agentName: "General Agent", - sessionId: "session-01ABC", - sessionTitle: "Fix (the) parser", - workspace: "/data/ws", - }; - expect(parseHandoffMessage(handoffMessage(origin))).toEqual(origin); - }); - - it("minimal origin (agent only) round-trips; with display name equal to the id, generation omits the parenthetical and parsing restores only the id", () => { - expect(parseHandoffMessage(handoffMessage({ agentId: "researcher" }))).toEqual({ - agentId: "researcher", - }); - expect( - parseHandoffMessage(handoffMessage({ agentId: "researcher", agentName: "researcher" })), - ).toEqual({ agentId: "researcher" }); - }); - - it("plain messages and longer messages merely containing an origin block are not misdetected", () => { - expect(parseHandoffMessage("hello @default_agent")).toBeNull(); - expect(parseHandoffMessage(`before\n${handoffMessage({ agentId: "a1" })}`)).toBeNull(); - expect(parseHandoffMessage(`${handoffMessage({ agentId: "a1" })}\nafter`)).toBeNull(); - }); -}); - -describe("parseScheduledMessage (parses the scheduled-task origin block, driving the schedule notice rendering)", () => { - /** Shape of the server-side scheduledMessage output (scheduler.ts). */ - const scheduled = (name: string, firedAt: string, prompt: string): string => - [ - "", - "This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.", - `schedule: ${name}`, - `fired_at: ${firedAt}`, - "", - "", - prompt, - ].join("\n"); - - it("parses the origin block and returns the remaining body (rendered as usual)", () => { - const text = scheduled( - "daily_report", - "2026-07-16T01:00:00.000Z", - "Write the daily report\nattach yesterday's data", - ); - expect(parseScheduledMessage(text)).toEqual({ - origin: { name: "daily_report", firedAt: "2026-07-16T01:00:00.000Z" }, - rest: "Write the daily report\nattach yesterday's data", + const switched = modelSwitchMessage({ sessionId: "session-01", tracePath: "/t.jsonl" }); + expect(switched.startsWith("[model_switch_from]\n")).toBe(true); + expect(parseModelSwitchMessage(switched)).toEqual({ + sessionId: "session-01", + tracePath: "/t.jsonl", }); }); - it("plain messages and mid-text blocks are not misdetected; a missing task name means it is not an origin block", () => { - expect(parseScheduledMessage("hello")).toBeNull(); - expect( - parseScheduledMessage(`preamble\n${scheduled("t", "2026-01-01T00:00:00Z", "p")}`), - ).toBeNull(); + it("the scheduled-task parser (server-produced block) is reachable here for the banner", () => { expect( parseScheduledMessage( - "\nfired_at: 2026-01-01T00:00:00Z\n\n\np", + "[scheduled_task]\nschedule: daily\nfired_at: 2026-01-01T00:00:00Z\n[/scheduled_task]\n\nbody", ), - ).toBeNull(); - }); -}); - -describe("modelSwitchMessage / parseModelSwitchMessage (the /model switch origin block)", () => { - it("round-trips a full origin (session title with parentheses, trace path, workspace, previous pair)", () => { - const origin = { - sessionId: "session-2026-07-24-10-00-00-abcdef01", - sessionTitle: "Fix (the) parser", - tracePath: "/data/p/agents/a/traces/2026-07-24/session-x_001.jsonl", - workspace: "/data/ws", - prevProvider: "deepseek", - prevModelId: "deepseek-v4-pro", - }; - expect(parseModelSwitchMessage(modelSwitchMessage(origin))).toEqual(origin); - }); - - it("minimal origin (session id only) round-trips; optional lines are omitted from the block", () => { - const text = modelSwitchMessage({ sessionId: "session-01" }); - expect(text).not.toContain("trace:"); - expect(text).not.toContain("workspace:"); - expect(text).not.toContain("previous_model:"); - expect(parseModelSwitchMessage(text)).toEqual({ sessionId: "session-01" }); - }); - - it("plain messages and longer messages merely containing the block are not misdetected", () => { - expect(parseModelSwitchMessage("hello /model")).toBeNull(); - expect( - parseModelSwitchMessage(`before\n${modelSwitchMessage({ sessionId: "s1" })}`), - ).toBeNull(); - expect(parseModelSwitchMessage(`${modelSwitchMessage({ sessionId: "s1" })}\nafter`)).toBeNull(); - // A block without a session line is not an origin block. - expect( - parseModelSwitchMessage("\ntrace: /t.jsonl\n"), - ).toBeNull(); + ).toEqual({ origin: { name: "daily", firedAt: "2026-01-01T00:00:00Z" }, rest: "body" }); }); }); diff --git a/packages/web/test/session-grouping.test.ts b/packages/web/test/session-grouping.test.ts index dfa7ecf..a5db6c0 100644 --- a/packages/web/test/session-grouping.test.ts +++ b/packages/web/test/session-grouping.test.ts @@ -48,6 +48,7 @@ function session( createdAt, status: "idle", pendingApprovalCount: 0, + pendingFollowUpCount: 0, hasTrace: false, archived: over.archived ?? false, ...(over.source !== undefined ? { source: over.source } : {}), diff --git a/packages/web/test/skill-use.test.ts b/packages/web/test/skill-use.test.ts index 4815b26..67f6b92 100644 --- a/packages/web/test/skill-use.test.ts +++ b/packages/web/test/skill-use.test.ts @@ -1,69 +1,24 @@ /** - * skill-use.ts unit tests: building and reverse-parsing the `` - * block (global convention format) — - * round-trip consistency, an empty skill list omits the block, and only a - * block at the start of the message is recognized. + * skill-use.ts unit test: the feature module re-exports the `[use_skills]` marker producer + * and parser from core's marker module — the block's own semantics (both forms, anchoring, + * legacy compat) are covered by packages/core/test/markers.test.ts. What matters here is + * that the wiring is intact for the components importing them from this module. */ import { describe, expect, it } from "vitest"; import { buildSkillsMessage, parseSkillsMessage } from "../src/features/chat/skill-use"; -describe("buildSkillsMessage ( block + body)", () => { - it("single skill: block (tag line, skills: line, closing tag) + blank line + body", () => { - expect(buildSkillsMessage(["penguin-sdk"], "write me a demo")).toBe( - "\nskills: penguin-sdk\n\n\nwrite me a demo", - ); - }); - - it("multiple skills: the skills: line is comma + space separated", () => { - expect(buildSkillsMessage(["agent-creation", "penguin-cli"], "x")).toBe( - "\nskills: agent-creation, penguin-cli\n\n\nx", - ); - }); - - it("an empty list adds no block: the body returns unchanged", () => { - expect(buildSkillsMessage([], "hello")).toBe("hello"); - expect(buildSkillsMessage([], "")).toBe(""); - }); - - it("block only without a body, no trailing blank line", () => { - expect(buildSkillsMessage(["solo"], "")).toBe("\nskills: solo\n"); - }); -}); - -describe("parseSkillsMessage (reverse parsing, driving the use-skills banner)", () => { - it("round-trips with build (multiple skills, multi-line body)", () => { +describe("skill-use re-exports the core [use_skills] marker helpers", () => { + it("builds the square-bracket block and parses it back (round-trip through the feature module)", () => { const text = buildSkillsMessage(["agent-creation", "penguin-cli"], "use them\nline2"); + expect(text.startsWith("[use_skills]\n")).toBe(true); expect(parseSkillsMessage(text)).toEqual({ skills: ["agent-creation", "penguin-cli"], rest: "use them\nline2", }); }); - it("a body containing < characters (including tag-like text) is unaffected", () => { - const body = - "compare a < b and keep markers\n appearing here is still just body text"; - expect(parseSkillsMessage(buildSkillsMessage(["x-skill"], body))).toEqual({ - skills: ["x-skill"], - rest: body, - }); - }); - - it("block only, no body: rest is an empty string", () => { - expect(parseSkillsMessage("\nskills: solo\n")).toEqual({ - skills: ["solo"], - rest: "", - }); - }); - - it("a block not at the start of the message (preceding body/whitespace) does not parse; plain messages do not parse", () => { - const block = buildSkillsMessage(["a"], "b"); - expect(parseSkillsMessage(`hi\n${block}`)).toBeNull(); - expect(parseSkillsMessage(` ${block}`)).toBeNull(); - expect(parseSkillsMessage("plain text mentioning only")).toBeNull(); - }); - - it("a blank skills: line means it is not a skills block", () => { - expect(parseSkillsMessage("\nskills: \n\n\nbody")).toBeNull(); - expect(parseSkillsMessage("\nskills: , ,\n")).toBeNull(); + it("an empty selection leaves the body untouched; a non-block message does not parse", () => { + expect(buildSkillsMessage([], "hello")).toBe("hello"); + expect(parseSkillsMessage("plain text mentioning [use_skills] only")).toBeNull(); }); }); diff --git a/packages/web/test/stream-model.test.ts b/packages/web/test/stream-model.test.ts index 382b03f..585e715 100644 --- a/packages/web/test/stream-model.test.ts +++ b/packages/web/test/stream-model.test.ts @@ -52,6 +52,7 @@ import type { TaskStatsItem, ThinkingItem, ToolCallItem, + UserSteeringItem, UserTextItem, } from "../src/lib/omni/stream-model"; @@ -556,7 +557,7 @@ describe("Task segmentation and stats triggering", () => { }); it("round end takes the last request_end: the next round's injection arriving after it does not inflate this round's elapsed time", () => { - // During history rebuild, the compaction summary `` is written alongside the + // During history rebuild, the compaction summary `[context_summary]` is written alongside the // next round, with its timestamp landing in that next round — it arrives while the previous // round is still open. If round-end took the latest message seen, this injection would // artificially inflate the previous round's elapsed time (the old bug where elapsed grows @@ -568,7 +569,7 @@ describe("Task segmentation and stats triggering", () => { at(tokenUsage(out(100), out(100)), "2026-07-05T00:00:02.000Z"), at(requestEnd("completed"), "2026-07-05T00:00:03.000Z"), // this round's last request_end // Next round's summary injection, timestamped much later; it arrives while this round hasn't closed yet. - at(userText("\nsummary\n"), "2026-07-05T00:00:50.000Z"), + at(userText("[context_summary]\nsummary\n[/context_summary]"), "2026-07-05T00:00:50.000Z"), at(userText("next question"), "2026-07-05T00:01:00.000Z"), // startTask: closes the previous round ]); finalizeHistory(m); @@ -793,7 +794,7 @@ describe("compaction attribution by position: in-round counts toward the round, compactionEnd({ reason: "manual", mode: "summarize", status: "completed" }), "2026-07-05T00:00:33.000Z", ), - at(userText("\nsummary\n"), "2026-07-05T00:00:33.000Z"), + at(userText("[context_summary]\nsummary\n[/context_summary]"), "2026-07-05T00:00:33.000Z"), ]); finalizeHistory(m); const stats = items(m).find((i) => i.kind === "task_stats") as TaskStatsItem; @@ -818,7 +819,7 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li ), // Compaction prompt (user text) and the compaction request's summary output (assistant text): internal messages. at(userText("Summarize the above (compaction prompt)"), "2026-07-05T00:00:06.100Z"), - at(assistantText("summary content"), "2026-07-05T00:00:09.000Z"), + at(assistantText("[summary]summary content[/summary]"), "2026-07-05T00:00:09.000Z"), at(tokenUsage(counts(11000), counts(1000)), "2026-07-05T00:00:09.500Z"), at( compactionEnd({ reason: "context", mode: "summarize", status: "completed" }), @@ -826,7 +827,7 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li ), // Summary injected at the start of the new context file: internal input. at( - userText("\nsummary content\n"), + userText("[context_summary]\nsummary content\n[/context_summary]"), "2026-07-05T00:00:10.500Z", ), at(userText("next question"), "2026-07-05T00:01:00.000Z"), @@ -870,14 +871,14 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li "2026-07-05T00:00:06.000Z", ), at(userText("compaction prompt"), "2026-07-05T00:00:06.100Z"), - at(assistantText("progress summary"), "2026-07-05T00:00:08.000Z"), + at(assistantText("[summary]progress summary[/summary]"), "2026-07-05T00:00:08.000Z"), at( compactionEnd({ reason: "context", mode: "summarize", status: "completed" }), "2026-07-05T00:00:09.000Z", ), // Mid-round compaction: the summary is written as the new context's first input, after end. at( - userText("\nprogress summary\n"), + userText("[context_summary]\nprogress summary\n[/context_summary]"), "2026-07-05T00:00:09.500Z", ), at(assistantText("continue fixing and finish"), "2026-07-05T00:00:12.000Z"), @@ -889,6 +890,55 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li expect((items(m)[2] as AssistantTextItem).text).toBe("continue fixing and finish"); expect(items(m).filter((i) => i.kind === "user_text")).toHaveLength(1); }); + + it("legacy prefix (old Traces) is still treated as internal input, not a user bubble", () => { + // Old Traces contain the angle-bracket form; re-rendering them must keep hiding the + // summary injection exactly like the current [context_summary] form. + const m = createStreamModel(); + pushMessages(m, [ + at( + userText("\nold summary\n"), + "2026-07-05T00:00:00.000Z", + ), + at(userText("continue the task"), "2026-07-05T00:00:01.000Z"), + at(assistantText("resuming"), "2026-07-05T00:00:02.000Z"), + ]); + finalizeHistory(m); + const users = items(m).filter((i) => i.kind === "user_text") as UserTextItem[]; + expect(users.map((u) => u.text)).toEqual(["continue the task"]); + }); + + it("[user_steering] user text renders as a steering chip inside the running Task — it never starts a new Task", () => { + // Mid-run steering is delivered as a standalone [user_steering] user message: it must + // stay inside the current Task (one stats row, one task) but render in-flow, marker + // stripped, as a user_steering item. + const m = createStreamModel(); + pushMessages(m, [ + at(userText("fix the bug"), "2026-07-05T00:00:00.000Z"), + at(assistantText("looking"), "2026-07-05T00:00:02.000Z"), + at(tokenUsage(counts(100), counts(100)), "2026-07-05T00:00:03.000Z"), + at( + userText("[user_steering]\nalso check the tests\n[/user_steering]"), + "2026-07-05T00:00:04.000Z", + ), + at(assistantText("checking the tests too"), "2026-07-05T00:00:06.000Z"), + at(tokenUsage(counts(200), counts(100)), "2026-07-05T00:00:07.000Z"), + ]); + finalizeHistory(m); + const kinds = items(m).map((i) => i.kind); + // One Task: user, assistant, steering chip, assistant, one stats row at the end. + expect(kinds).toEqual([ + "user_text", + "assistant_text", + "user_steering", + "assistant_text", + "task_stats", + ]); + const steering = items(m).find((i) => i.kind === "user_steering") as UserSteeringItem; + expect(steering.text).toBe("also check the tests"); + // Only the real prompt is a user bubble. + expect(items(m).filter((i) => i.kind === "user_text")).toHaveLength(1); + }); }); describe("live close-out elapsed (#5/#20: mid-join takes the message-timestamp lower bound)", () => {