fix(server,web): steering survives reloads with visible content, and file attachments steer too (#157)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-03 15:35:32 +08:00
committed by GitHub
parent 835dfb5c5a
commit a0968496b7
17 changed files with 554 additions and 68 deletions
+33 -4
View File
@@ -689,16 +689,39 @@ export interface TaskCreateResponse {
* task POST).
*/
export interface SteerRequest {
/** Message text (trimmed server-side); may be empty when `images` carries the message. */
/** Message text (trimmed server-side); may be empty when `images` or `files` carries the message. */
text: string;
/**
* Images sent with the steering message (`data:` or http(s) URLs, same rule as
* `TaskInputPart.image_url`): delivered as user image messages right behind the
* `[user_steering]` text. A model without vision receives them as scratchpad path lines
* instead, exactly as it would a Prompt's images. At least one of `text` / `images` must
* be non-empty.
* instead, exactly as it would a Prompt's images. At least one of `text` / `images` /
* `files` must be non-empty.
*/
images?: string[];
/**
* File attachments riding the steering message — the same shape, caps and handling as a
* task input's `{type:"file"}` parts: written into the Session scratchpad and delivered
* as `[attached file: <path>]` lines on the `[user_steering]` text, so a file-only draft
* steers exactly like an image-only one instead of falling back to the follow-up queue.
*/
files?: { fileName: string; dataUrl: string }[];
}
/**
* One steering message queued on the server but not yet delivered to the model (delivery
* happens at the next input assembly between turns). Carried on `task_state` events and the
* SSE subscribe snapshot so the composer's "steering queued" hint — including what was sent —
* survives reloads; entries leave the list as their `[user_steering]` message appears on the
* stream, and the whole list drops when the run exits (core discards undelivered steering).
*/
export interface PendingSteeringInfo {
/** The message text as accepted (trimmed); may be empty when images/files carry the message. */
text: string;
/** Number of images that rode along. */
images: number;
/** Number of file attachments that rode along. */
files: number;
}
export interface ApprovalDecisionRequest {
@@ -727,7 +750,13 @@ export type ServerEvent =
*/
| { type: "approval_request"; toolCall: OmniMessage<ToolCallPayload>; origin?: string[] }
/** 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 }
| {
type: "task_state";
state: SessionStatus;
queued?: number;
/** Steering messages queued but not yet delivered (absent = none): lets the composer's hint and its content survive reloads. */
pendingSteering?: PendingSteeringInfo[];
}
/** 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. */
+50 -9
View File
@@ -191,6 +191,26 @@ function parseSteerImages(body: Record<string, unknown>): string[] {
return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
}
/**
* Validate the optional `files` field of a steer request: the same shape and caps as a task
* input's `{type:"file"}` parts (parseAttachmentPart, budget re-checked per item), absent or
* empty = no attachments.
*/
function parseSteerFiles(body: Record<string, unknown>): TaskAttachment[] {
const files = body.files;
if (files === undefined) return [];
if (!Array.isArray(files)) throw badRequest("files must be an array.");
const attachments: TaskAttachment[] = [];
files.forEach((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`files[${i}] must be an object.`);
}
attachments.push(parseAttachmentPart(item as Record<string, unknown>, i, "files"));
assertAttachmentBudget(attachments);
});
return attachments;
}
/**
* Validate the optional `goal` field of a task request: absent = a regular task (null);
* present = goal mode with a token budget (a positive integer, or -1/omitted = unlimited).
@@ -506,11 +526,15 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
// this as authoritative, eliminating input-area lockup or premature Task closure
// caused by a stale running/idle in the list; followed by replaying all still-pending
// approval requests.
const pendingSteering = deps.manager.pendingSteeringOf(row.sessionId);
const initialEvents: ServerEvent[] = [
{
type: "task_state",
state: deps.manager.statusOf(row.sessionId),
queued: deps.manager.pendingFollowUpCount(row.sessionId),
// Undelivered steering rides the snapshot too, so the composer's "steering queued"
// hint (and what it says) survives a reload.
...(pendingSteering.length > 0 ? { pendingSteering } : {}),
},
...deps.manager.pendingApprovals(row.sessionId).map((p) => ({
type: "approval_request" as const,
@@ -602,18 +626,35 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const body = await readJson(c);
const text = typeof body.text === "string" ? body.text.trim() : "";
const images = parseSteerImages(body);
// Either half can carry the message on its own: an image with no caption is a complete
// steering message, and so is plain text.
if (!text && images.length === 0) {
throw badRequest("text or images must carry the steering message.");
const files = parseSteerFiles(body);
// Any part can carry the message on its own: an image or a file with no caption is a
// complete steering message, and so is plain text.
if (!text && images.length === 0 && files.length === 0) {
throw badRequest("text, images or files must carry the steering message.");
}
// The wire shape becomes core's: a user text message (omitted when the images are the
// whole message, so the fold's path lines aren't preceded by a blank one) plus one image
// message each — the same input a normal task would carry.
deps.manager.steer(row.sessionId, [
...(text ? [userText(text)] : []),
...images.map((url) => imageUrlMessage(url)),
]);
// message each — the same input a normal task would carry. File attachments land in the
// Session scratchpad exactly as a task's do and ride as `[attached file: <path>]` lines
// on the steering text (a files-only input becomes a line-only text message).
const { input, written } = await attachFilesToInput(
[...(text ? [userText(text)] : []), ...images.map((url) => imageUrlMessage(url))],
files,
scratchpadDir(deps.config.root, row.projectId, row.agentId),
row.sessionId,
);
try {
deps.manager.steer(row.sessionId, input, {
text,
images: images.length,
files: files.length,
});
} catch (err) {
// 409 (not running) or any other refusal: the files must not stay behind — the
// frontend falls back to a normal task POST, which writes its own copies.
await removeAttachments(written);
throw err;
}
return c.body(null, 202);
});
+51 -5
View File
@@ -34,6 +34,7 @@ import {
goalTokenDelta,
isGoalRoundInput,
isSessionMeta,
parseUserSteeringText,
stripLeadingMarkerBlocks,
tracesDir,
} from "@prismshadow/penguin-core";
@@ -46,7 +47,7 @@ import type {
TextPayload,
ThinkingLevelName,
} from "@prismshadow/penguin-core";
import type { ServerEvent, SessionStatus } from "../api/types.js";
import type { PendingSteeringInfo, ServerEvent, SessionStatus } from "../api/types.js";
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
import type { GoalsRepo } from "../db/repos/goals.js";
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
@@ -241,6 +242,14 @@ interface RuntimeEntry {
* Deliberately NOT discarded on abort: they are future tasks the user explicitly queued.
*/
followUps: QueuedFollowUp[];
/**
* Steering messages queued on core but not yet delivered to the model — a display mirror
* of core's steering queue (same FIFO order), so the composer's "steering queued" hint and
* its content survive reloads: pushed on `steer`, shifted as each `[user_steering]`
* message appears on the drive stream, dropped wholesale when the run exits (core
* discards undelivered steering on abort/completion). Broadcast on every `task_state`.
*/
pendingSteering: PendingSteeringInfo[];
/** Timestamp of last activity (refreshed on load / status flip / drive completion), used for idle-eviction checks. */
lastActivityMs: number;
}
@@ -265,6 +274,13 @@ function agentKey(projectId: string, agentId: string): string {
}
/** If msg is a run_subagent tool call carrying a `prompt`, return its id and prompt (for use as the subagent's title); otherwise null. */
/** Whether this main-stream message is a delivered `[user_steering]` user text (one per queued steering entry, see core's steeringMessages). */
function isDeliveredSteering(msg: OmniMessage): boolean {
const p = msg.payload as { type?: string; role?: string; text?: string };
if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user") return false;
return typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
}
function runSubagentCall(msg: OmniMessage): { toolCallId: string; prompt: string } | null {
const p = msg.payload as {
type?: string;
@@ -372,6 +388,11 @@ export class SessionManager {
return this.entries.get(sessionId)?.followUps.length ?? 0;
}
/** Steering messages queued but not yet delivered to the model (display mirror; see RuntimeEntry.pendingSteering). */
pendingSteeringOf(sessionId: string): PendingSteeringInfo[] {
return this.entries.get(sessionId)?.pendingSteering ?? [];
}
/**
* Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
* streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
@@ -406,6 +427,7 @@ export class SessionManager {
running: null,
generation: this.generationOf(row.projectId, row.agentId),
followUps: [],
pendingSteering: [],
lastActivityMs: Date.now(),
});
}
@@ -783,8 +805,13 @@ export class SessionManager {
* 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, which carries the same text and images.
*
* `info` is the queued message's display summary: mirrored on the entry and broadcast via
* `task_state` (and the SSE subscribe snapshot) until the delivered `[user_steering]`
* message is observed on the stream — that is what keeps the composer's "steering queued"
* hint, content included, alive across reloads.
*/
steer(sessionId: string, input: OmniMessage[]): void {
steer(sessionId: string, input: OmniMessage[], info: PendingSteeringInfo): void {
const entry = this.entries.get(sessionId);
if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
throw new HttpError(
@@ -793,7 +820,9 @@ export class SessionManager {
"This Session has no Task in progress; send the message as a new task instead.",
);
}
entry.pendingSteering.push(info);
entry.lastActivityMs = Date.now();
this.publishState(entry, entry.status);
}
/**
@@ -1039,6 +1068,7 @@ export class SessionManager {
running: null,
generation,
followUps: [],
pendingSteering: [],
lastActivityMs: Date.now(),
};
this.entries.set(currentId, entry);
@@ -1112,6 +1142,13 @@ export class SessionManager {
if (denied) subagentPrompts.delete(denied);
const settled = settledToolCallId(msg);
if (settled) subagentPrompts.delete(settled);
// Steering delivery: core emits exactly one `[user_steering]` user text per queued
// entry, in queue order — shift the display mirror and re-broadcast so the
// composer's "steering queued" hint retires the moment the message is on stream.
if (entry.pendingSteering.length > 0 && isDeliveredSteering(msg)) {
entry.pendingSteering.shift();
this.publishState(entry, entry.status);
}
} else if (isSessionMeta(msg)) {
// Subagent registration is only a "side effect" — it must never interrupt the
// main run flow on error: wrap the whole thing in a defensive try/catch.
@@ -1201,6 +1238,10 @@ export class SessionManager {
entry.status = "idle";
entry.abort = null;
entry.running = null;
// The run is over, so core has discarded any undelivered steering (see ContextEngine's
// steeringQueue) — drop the mirror with it; the idle publish below broadcasts the
// now-empty state.
entry.pendingSteering = [];
entry.lastActivityMs = Date.now();
this.publishState(entry, "idle");
if (titleSource && titleSource.userExcerpt.trim()) {
@@ -1305,9 +1346,14 @@ export class SessionManager {
}
private publishState(entry: RuntimeEntry, state: SessionStatus): void {
// 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 });
// Every state flip also reports the queued follow-up count and the undelivered steering
// mirror, so subscribers can render both hints without a dedicated event type.
this.publishEvent(entry, {
type: "task_state",
state,
queued: entry.followUps.length,
...(entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering } : {}),
});
}
private publishEvent(entry: RuntimeEntry, event: ServerEvent): void {
@@ -105,10 +105,15 @@ export interface TaskAttachment {
/**
* Validate one `{type:"file"}` input part. Shape problems are 400s in the same style as the
* neighbouring text/image checks; only the size cap answers 413 (`file_too_large`, the code
* the Web App already has copy for). `index` is the part's position in `input`, so the message
* points at the offending item like the other input errors do.
* the Web App already has copy for). `index` is the part's position in the request array
* named by `field` (default `input`; the steer route passes `files`), so the message points
* at the offending item like the other input errors do.
*/
export function parseAttachmentPart(part: Record<string, unknown>, index: number): TaskAttachment {
export function parseAttachmentPart(
part: Record<string, unknown>,
index: number,
field = "input",
): TaskAttachment {
const fileName = part.fileName;
// Path separators and `..` are rejected rather than sanitized away: the name is the user's,
// and a name that looks like a path means the caller is confused about the contract (the
@@ -122,7 +127,7 @@ export function parseAttachmentPart(part: Record<string, unknown>, index: number
fileName.includes("\0")
) {
throw badRequest(
`input[${index}].fileName must be a non-empty file name without path separators or "..".`,
`${field}[${index}].fileName must be a non-empty file name without path separators or "..".`,
);
}
const dataUrl = part.dataUrl;
@@ -133,11 +138,11 @@ export function parseAttachmentPart(part: Record<string, unknown>, index: number
const match =
typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
if (!match) {
throw badRequest(`input[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
throw badRequest(`${field}[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
}
const bytes = Buffer.from(match[1]!, "base64");
if (bytes.length === 0) {
throw badRequest(`input[${index}].dataUrl decodes to an empty file.`);
throw badRequest(`${field}[${index}].dataUrl decodes to an empty file.`);
}
if (bytes.length > MAX_ATTACHMENT_BYTES) {
throw new HttpError(
+57 -1
View File
@@ -20,6 +20,7 @@ import {
thinkingMessage,
toolCall,
toolCallOutput,
userSteeringText,
userText,
withOrigin,
} from "@prismshadow/penguin-core";
@@ -248,7 +249,7 @@ describe("session-manager", () => {
const manager = makeManager(loaderOf(fake));
const steerErr = (text: string): unknown => {
try {
manager.steer("session-1", [userText(text)]);
manager.steer("session-1", [userText(text)], { text, images: 0, files: 0 });
return null;
} catch (e) {
return e;
@@ -277,6 +278,61 @@ describe("session-manager", () => {
expect((steerErr("post") as HttpError).code).toBe("not_running");
});
it("pendingSteering mirror: broadcast with task_state on steer, shifted at delivery, dropped at run end", async () => {
// Two approval parks, with core's delivery shape — one `[user_steering]` user text —
// yielded between them, so the mid-run shift is observable while the run keeps going.
const fake: RuntimeSession = {
...approvalFakeSession("session-1"),
steer: () => true,
async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
const tc1 = toolCall({ name: "write_file", arguments: "{}", toolCallId: "tc-1" });
yield tc1;
yield approvalDecision(await opts.approve(tc1), "tc-1");
yield userText(userSteeringText("focus on tests"));
const tc2 = toolCall({ name: "write_file", arguments: "{}", toolCallId: "tc-2" });
yield tc2;
yield approvalDecision(await opts.approve(tc2), "tc-2");
yield assistantText("done");
},
};
const manager = makeManager(loaderOf(fake));
const events = capture("session-1");
await manager.startTask("session-1", [userText("go")]);
await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
// Two queued steering messages: the mirror keeps both, in queue order.
manager.steer("session-1", [userText("a")], { text: "focus on tests", images: 0, files: 0 });
manager.steer("session-1", [userText("b")], { text: "later", images: 1, files: 2 });
expect(manager.pendingSteeringOf("session-1")).toEqual([
{ text: "focus on tests", images: 0, files: 0 },
{ text: "later", images: 1, files: 2 },
]);
// First delivery observed on the stream: the mirror shifts while the run is still going.
manager.decideApproval("session-1", "tc-1", "allow");
await waitFor(() => manager.pendingSteeringOf("session-1").length === 1);
expect(manager.statusOf("session-1")).toBe("running");
expect(manager.pendingSteeringOf("session-1")).toEqual([
{ text: "later", images: 1, files: 2 },
]);
// Run end: core discards undelivered steering, and the mirror goes with it.
await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
manager.decideApproval("session-1", "tc-2", "allow");
await waitFor(() => manager.statusOf("session-1") === "idle");
expect(manager.pendingSteeringOf("session-1")).toEqual([]);
// task_state broadcasts traced the whole lifecycle: grow to 1, 2, shift to 1, gone at idle.
const states = serverEvents(events).filter((e) => e.type === "task_state");
const mirrored = states
.filter((e) => e.pendingSteering !== undefined)
.map((e) => (e.pendingSteering as unknown[]).length);
expect(mirrored).toEqual([1, 2, 1]);
const last = states[states.length - 1]!;
expect(last.state).toBe("idle");
expect(last.pendingSteering).toBeUndefined();
});
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[][] = [];
+115 -2
View File
@@ -1,13 +1,26 @@
/**
* Integration tests for POST /api/sessions/:id/steer (mid-run steering):
* - 202 while a Task is running, forwarding the trimmed text and its images to the core session;
* - 400 when neither text nor images carry a message, and for malformed image URLs;
* - 400 when neither text nor images nor files carry a message, and for malformed
* image URLs / file parts;
* - file attachments land in the Session scratchpad and ride the steering text as
* `[attached file: <path>]` lines (a 409 cleans them up again);
* - the SSE subscribe snapshot carries the pending-steering mirror (task_state), which is
* what keeps the composer's "steering queued" hint alive across reloads;
* - 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 { readFile, readdir, realpath } from "node:fs/promises";
import path from "node:path";
import {
approvalDecision,
assistantText,
scratchpadDir,
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";
@@ -154,4 +167,104 @@ describe("steer route", () => {
const res = await api.post(`/api/sessions/session-ghost/steer`, { text: "x" });
expect(res.status).toBe(404);
});
it("files ride the steering text as [attached file] lines — and carry the message alone", async () => {
await t.deps.manager.startTask(SID, [userText("go")]);
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
const data = `data:text/plain;base64,${Buffer.from("hello notes").toString("base64")}`;
const captioned = await api.post(`/api/sessions/${SID}/steer`, {
text: " read this ",
files: [{ fileName: "notes.txt", dataUrl: data }],
});
expect(captioned.status).toBe(202);
// A file with no caption is a complete steering message: the attachment line becomes a
// line-only text message (same shared rule as a task's attachments-only input).
const bare = await api.post(`/api/sessions/${SID}/steer`, {
text: "",
files: [{ fileName: "solo.txt", dataUrl: data }],
});
expect(bare.status).toBe(202);
expect(steered).toHaveLength(2);
const first = shape(steered[0]!);
expect(first).toHaveLength(1);
expect(first[0]).toMatch(/^text:read this\n\n\[attached file: .*notes\.txt\]$/);
const second = shape(steered[1]!);
expect(second).toHaveLength(1);
expect(second[0]).toMatch(/^text:\[attached file: .*solo\.txt\]$/);
// The bytes really landed in this Session's scratchpad. Directories are compared via
// realpath, not string prefixes: on the Windows CI runner the temp root mixes 8.3
// short and long name forms, so two spellings of the same directory are expected.
const written = /\[attached file: (.*)\]/.exec(first[0]!)![1]!;
const expectedDir = path.join(
scratchpadDir(t.root, "steerer-default_project", "default_agent"),
SID,
);
expect(await realpath(path.dirname(written))).toBe(await realpath(expectedDir));
expect(path.basename(written)).toBe("notes.txt");
expect(await readFile(written, "utf8")).toBe("hello notes");
// Same validation as a task input's file parts, under the steer request's own field name.
expect(
(await api.post(`/api/sessions/${SID}/steer`, { text: "x", files: "nope" })).status,
).toBe(400);
const evil = await api.post(`/api/sessions/${SID}/steer`, {
text: "x",
files: [{ fileName: "../evil.txt", dataUrl: data }],
});
expect(evil.status).toBe(400);
expect(((await evil.json()) as { error: { message: string } }).error.message).toContain(
"files[0]",
);
expect(
(
await api.post(`/api/sessions/${SID}/steer`, {
text: "x",
files: [{ fileName: "a.txt", dataUrl: "nope" }],
})
).status,
).toBe(400);
expect(steered).toHaveLength(2);
t.deps.manager.decideApproval(SID, "tc-steer", "allow");
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
});
it("a 409 steer leaves no attachment behind (the fallback normal send writes its own copy)", async () => {
const data = `data:text/plain;base64,${Buffer.from("orphan?").toString("base64")}`;
const res = await api.post(`/api/sessions/${SID}/steer`, {
text: "",
files: [{ fileName: "orphan.txt", dataUrl: data }],
});
expect(res.status).toBe(409);
const dir = path.join(scratchpadDir(t.root, "steerer-default_project", "default_agent"), SID);
expect(await readdir(dir).catch(() => [])).toEqual([]);
});
it("the SSE subscribe snapshot carries the pending-steering mirror (what makes the hint survive reloads)", async () => {
await t.deps.manager.startTask(SID, [userText("go")]);
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
await api.post(`/api/sessions/${SID}/steer`, { text: "hold on" });
expect(t.deps.manager.pendingSteeringOf(SID)).toEqual([
{ text: "hold on", images: 0, files: 0 },
]);
// The first SSE frames are the initial task_state snapshot: it must carry the mirror.
const res = await api.get(`/api/sessions/${SID}/stream`);
const reader = res.body!.getReader();
let seen = "";
for (let i = 0; i < 5 && !seen.includes("task_state"); i += 1) {
const { value, done } = await reader.read();
if (done) break;
seen += new TextDecoder().decode(value);
}
await reader.cancel();
expect(seen).toContain('"task_state"');
expect(seen).toContain('"pendingSteering"');
expect(seen).toContain("hold on");
t.deps.manager.decideApproval(SID, "tc-steer", "allow");
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Steering across a reload (#136/#140): a message steered into a running Task must
* (a) leave no composer draft behind — reloading must not resurrect the sent text (the
* old behavior revived it as a draft, and re-sending duplicated the steering);
* (b) keep its "queued" hint — content included — across the reload (the server's
* pending-steering mirror rides task_state events and the SSE subscribe snapshot);
* (c) land in the transcript exactly once, as a [user_steering] chip, once delivered.
*
* The LLM is mock-llm.mjs's "slow stream test": a ~8s exec_command keeps the Task busy,
* leaving a wide window to steer and reload before delivery (steering is delivered at the
* next input assembly, i.e. when the tool output returns).
*/
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 = "steeruser";
const P = "password123";
const STEER_TEXT = "steer: also check the logs";
/** Create a session for the user's auto-provisioned project (models PUT is idempotent). */
async function createSession(page, approvalMode) {
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
const projectId = projects.projects[0].projectId;
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
data: {
defaultModel: { provider: "custom", modelId: "claude-4-8" },
models: [
{
provider: "custom",
modelId: "claude-4-8",
apiKey: "sk-mock",
baseUrl: MOCK,
contextWindow: 200000,
},
],
},
});
expect(put.ok(), "put models").toBeTruthy();
const res = await page.request.post(
`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
{ data: { provider: "custom", modelId: "claude-4-8", approvalMode } },
);
expect(res.ok(), `create session: ${await res.text()}`).toBeTruthy();
return (await res.json()).session.sessionId;
}
test("a steered message survives reload as a queued hint with its content — not a draft — and lands exactly once", async ({
page,
}) => {
await provisionAndLogin(page.request, U, P);
const sessionId = await createSession(page, "always-ask");
await page.goto(`${BASE}/chat/${sessionId}`);
const ta = page.locator("textarea").first();
await ta.waitFor();
await ta.fill("slow stream test");
await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText("exec_command").first()).toBeVisible();
await page.getByRole("button", { name: "允许" }).click();
// Steer while the ~8s tool run keeps the Task busy (default mid-run mode is steer).
await ta.fill(STEER_TEXT);
await ta.press("Enter");
// The queued hint shows the content (the server mirror, not just the local flag), and
// the composer is cleared.
await expect(page.getByText(`插话已排队,将随下一轮送达:${STEER_TEXT}`)).toBeVisible();
await expect(ta).toHaveValue("");
// Reload mid-run: the sent text must NOT come back as a draft (#136 — that resurrection
// is what produced duplicates), while the queued hint returns with its content.
await page.reload();
const ta2 = page.locator("textarea").first();
await ta2.waitFor();
await expect(page.getByText(`插话已排队,将随下一轮送达:${STEER_TEXT}`)).toBeVisible({
timeout: 5000,
});
await expect(ta2).toHaveValue("");
// Delivered with the next turn: the [user_steering] chip carries the text exactly once
// (one paragraph holds the chip label and the text), and the queued hint retires — one
// paragraph total also proves the hint no longer repeats the content.
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("用户插话").first()).toBeVisible();
await expect(page.locator("p", { hasText: STEER_TEXT })).toHaveCount(1);
await expect(page.getByText(/插话已排队/)).toHaveCount(0);
});
+63 -23
View File
@@ -69,6 +69,7 @@ import type {
ApprovalMode,
ModelInfo,
ModelRefDto,
PendingSteeringInfo,
SessionStatus,
SkillMetadataItem,
TaskInputPart,
@@ -1116,6 +1117,16 @@ function readDataUrl(file: File): Promise<string | null> {
* One place, because every send path submits the same draft: the normal send, the follow-up
* queue, the @ handoff and the `/model` switch.
*/
/** One-line summary of a queued steering message: its text, then image/file counts for what the text cannot show. */
function steeringSummary(p: PendingSteeringInfo): string {
const parts: string[] = [];
const line = p.text.replace(/\s+/g, " ").trim();
if (line) parts.push(line);
if (p.images > 0) parts.push(S.chat.imagesInMessage(p.images));
if (p.files > 0) parts.push(S.chat.filesInMessage(p.files));
return parts.join(" \u00b7 ");
}
function appendAttachmentParts(
input: TaskInputPart[],
images: string[],
@@ -1132,6 +1143,7 @@ export function ChatInput({
onSend,
onSteer,
steeringDeliveredCount,
pendingSteering = [],
onQueueFollowUp,
queuedFollowUps = 0,
onStop,
@@ -1179,18 +1191,30 @@ export function ChatInput({
onSend: (input: TaskInputPart[], goal: { budget: number } | null) => Promise<boolean>;
/**
* Mid-run steering (session state only): while a Task is running, Enter/send queues the
* trimmed text **and any attached images** for the running agent — delivered between turns
* as a standalone `[user_steering]` user message followed by its images. `"queued"` clears
* the text and images 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.
* trimmed text **and any attached images and files** for the running agent — delivered
* between turns as a standalone `[user_steering]` user message followed by its images,
* with the files riding the text as `[attached file: <path>]` lines (#140: a file-only
* draft steers exactly like an image-only one). `"queued"` clears the text, images and
* files 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, images: string[]) => Promise<"queued" | "not_running" | "failed">;
onSteer?: (
text: string,
images: string[],
files: { fileName: string; dataUrl: 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;
/**
* The server's undelivered-steering mirror (from task_state events): each entry renders as
* a "steering queued" line with its content, so the hint — and what was sent — survives
* reloads (#136). The local post-202 flag only bridges until the first event arrives.
*/
pendingSteering?: PendingSteeringInfo[];
/**
* 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
@@ -1498,13 +1522,14 @@ export function ChatInput({
[onHandoffTargetChange, onPendingModelChange],
);
// Mid-run steering: while running, Enter/send queues the text **and the attached images**
// for the running agent (delivered between turns as a [user_steering] user message followed
// by its images) — so an image with no caption is a complete steering message on its own.
// File attachments and selected skills stay in the draft for a later normal send: a
// [use_skills] block is task-level setup, not something to hand a turn already under way. A
// staged /agent or /model chip also blocks steering: the text belongs to the conversation
// that switch is about to open, not to the agent running here.
// Mid-run steering: while running, Enter/send queues the text **and the attached images
// and files** for the running agent (delivered between turns as a [user_steering] user
// message followed by its images; files ride the text as [attached file: <path>] lines) —
// so an image or a file with no caption is a complete steering message on its own (#140).
// Selected skills stay in the draft for a later normal send: a [use_skills] block is
// task-level setup, not something to hand a turn already under way. A staged /agent or
// /model chip also blocks steering: the text belongs to the conversation that switch is
// about to open, not to the agent running here.
// `!goalOn`: with the goal chip engaged the text is an OBJECTIVE — steering it into a run
// that happens to be active (e.g. a schedule fired) would silently repurpose it.
//
@@ -1535,6 +1560,7 @@ export function ChatInput({
hasPendingModel: pendingModel !== null,
hasText: text.trim().length > 0,
hasImages: images.length > 0,
hasFiles: attachments.length > 0,
hasContent: draftHasContent,
});
const steerAction = running && midRun === "steer";
@@ -1976,16 +2002,17 @@ export function ChatInput({
await sendNormal(onQueueFollowUp!);
return;
}
// Steering branch: queue the trimmed text and the attached images for the running agent;
// both are sent and cleared together — file attachments and selected skills stay for a
// normal send (a staged switch chip blocks this branch outright, see midRunAction).
// Steering branch: queue the trimmed text, the attached images and the attached files
// for the running agent; all are sent and cleared together — selected skills stay for
// a normal send (a staged switch chip blocks this branch outright, see midRunAction).
if (!steerAction) return;
const steerText = text.trim();
const steerImages = images;
const steerFiles = attachments.map((f) => ({ fileName: f.name, dataUrl: f.dataUrl }));
setBusy(true);
let res: "queued" | "not_running" | "failed" = "failed";
try {
res = await onSteer!(steerText, steerImages);
res = await onSteer!(steerText, steerImages, steerFiles);
if (res === "queued") {
// Show the "queued" hint until the steering message shows up in the stream
// (steeringDeliveredCount increases) — see the effect below.
@@ -1993,6 +2020,7 @@ export function ChatInput({
setSteerPending(true);
setText("");
setImages([]);
setAttachments([]);
}
} finally {
setBusy(false);
@@ -2275,12 +2303,24 @@ export function ChatInput({
</p>
)}
{/* Mid-run steering queued: lightweight hint until the steering message appears in the
stream (or the run ends). */}
{steerPending && (
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
{S.chat.steerQueuedIndicator}
</p>
{/* Mid-run steering queued: the server's undelivered-steering mirror, one line per
queued message with its content — task_state-fed, so it survives reloads (#136,
#140). The local steerPending flag only bridges the gap between the 202 and the
first task_state that carries the mirror. */}
{pendingSteering.length > 0 ? (
<div className="anim-fade mb-1">
{pendingSteering.map((p, i) => (
<p key={i} className="truncate text-xs text-gray-400 dark:text-gray-500">
{S.chat.steerQueuedItem(steeringSummary(p))}
</p>
))}
</div>
) : (
steerPending && (
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
{S.chat.steerQueuedIndicator}
</p>
)
)}
{/* Queued follow-ups (server-side, auto-sent once this run finishes): count from
+20 -6
View File
@@ -721,14 +721,27 @@ export function ChatPage() {
// Mid-run steering: the message is queued on the server and delivered between turns as a
// standalone `[user_steering]` user message followed by its images (visible once they
// arrive 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 — skills and the whole draft included — rather than a text+images task.
// arrive over SSE / from the Trace); file attachments land in the Session scratchpad and
// ride the steering text as `[attached file: <path>]` lines, exactly as a task's do. On
// "queued" the localStorage draft is discarded, like a successful send — without this a
// reload resurrects the already-sent text as a draft, and re-sending it duplicates the
// steering message (#136). "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 —
// skills and the whole draft included — rather than a text+images task.
const onSteer = useCallback(
async (text: string, images: string[] = []): Promise<"queued" | "not_running" | "failed"> => {
async (
text: string,
images: string[] = [],
files: { fileName: string; dataUrl: string }[] = [],
): Promise<"queued" | "not_running" | "failed"> => {
if (!selected) return "failed";
try {
await api.postSteer(selected.sessionId, { text, ...(images.length > 0 ? { images } : {}) });
await api.postSteer(selected.sessionId, {
text,
...(images.length > 0 ? { images } : {}),
...(files.length > 0 ? { files } : {}),
});
discardSessionDraft();
return "queued";
} catch (e) {
if (e instanceof ApiError && e.status === 409) return "not_running";
@@ -736,7 +749,7 @@ export function ChatPage() {
return "failed";
}
},
[selected],
[selected, discardSessionDraft],
);
const onApprove = useCallback(
@@ -881,6 +894,7 @@ export function ChatPage() {
// Count of steering messages already visible in the stream: the input area keeps its
// "queued" indicator up until this count increases (i.e. the steering message arrived).
steeringDeliveredCount={stream.model.items.filter((i) => i.kind === "user_steering").length}
pendingSteering={stream.pendingSteering}
onQueueFollowUp={onQueueFollowUp}
queuedFollowUps={stream.queuedFollowUps}
onStop={onStop}
@@ -16,7 +16,7 @@
export type MidRunAction =
/** Abort the running Task — the button's face whenever this draft has no send channel. */
| "stop"
/** Deliver text and images to the running agent as a `[user_steering]` message. */
/** Deliver text, images and file attachments to the running agent as a `[user_steering]` message. */
| "steer"
/** Hold the whole draft server-side and auto-send it as the next ordinary message. */
| "queue"
@@ -46,6 +46,8 @@ export interface MidRunComposerState {
hasText: boolean;
/** At least one image is attached. */
hasImages: boolean;
/** At least one file attachment is staged (steers as `[attached file: <path>]` lines, like a task's). */
hasFiles: boolean;
/**
* The draft carries anything at all — text, images, file attachments, a staged switch chip
* or selected skills. The queue takes a whole message, so this is its content rule.
@@ -58,8 +60,8 @@ export interface MidRunComposerState {
* the button is always Send there, gated by the ordinary `canSend`.
*
* Steering is preferred over the queue when both could carry the draft, since it reaches the
* turn already under way; the queue picks up what steering cannot — a skills-only draft, file
* attachments, a staged chip — so that "unsendable here" never costs the user Stop.
* turn already under way; the queue picks up what steering cannot — a skills-only draft, a
* staged chip — so that "unsendable here" never costs the user Stop.
*/
export function midRunAction(s: MidRunComposerState): MidRunAction {
if (s.sending) return "disabled";
@@ -71,7 +73,7 @@ export function midRunAction(s: MidRunComposerState): MidRunAction {
s.canSteerChannel &&
!s.hasHandoffTarget &&
!s.hasPendingModel &&
(s.hasText || s.hasImages);
(s.hasText || s.hasImages || s.hasFiles);
if (canSteer && !s.followUpMode) return "steer";
const canQueue = open && s.canQueueChannel && s.stagedRoute !== "blocked" && s.hasContent;
if (canQueue) return "queue";
@@ -132,9 +132,11 @@ export function useSessionDraft(sessionId: string | null): {
const discard = useCallback(() => {
cancelPending();
// Also clear the selected skills and both staged switch chips: ChatInput's clear after a
// successful send doesn't fire a callback (same convention as onTextChange); without this,
// a later text flush would resurrect the already-sent selection.
// Also clear the text, the selected skills and both staged switch chips: ChatInput's
// clear after a successful send doesn't fire a callback (same convention as
// onTextChange); without this, a later flush triggered by any discrete action (a skill
// toggle, a staged chip) would resurrect the already-sent text or selection.
textRef.current = "";
skillsRef.current = [];
handoffRef.current = null;
switchModelRef.current = null;
@@ -16,7 +16,11 @@
* server re-sends still-pending requests.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { GoalServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
import type {
GoalServerEvent,
PendingSteeringInfo,
SessionStatus,
} from "@prismshadow/penguin-server/api";
import { getGoal, getMe, getMessages } from "../../api/endpoints";
import { openSessionStream } from "../../api/sse";
import { createStreamController } from "../../lib/omni/stream-controller";
@@ -39,6 +43,8 @@ export interface SessionStreamState {
taskState: SessionStatus;
/** Queued follow-up count from the stream's task_state events (auto-sent once the session is idle). */
queuedFollowUps: number;
/** Steering messages queued on the server but not yet delivered (from task_state events): keeps the composer's hint and its content across reloads. */
pendingSteering: PendingSteeringInfo[];
/**
* Timestamp (ms) of the last main-session auth failure (request_end with status "auth"),
* or null. Derived from the model, so it survives history replay and resets when
@@ -86,6 +92,7 @@ export function useSessionStream(
const [loading, setLoading] = useState(true);
const [taskState, setTaskState] = useState<SessionStatus>(initialStatus);
const [queuedFollowUps, setQueuedFollowUps] = useState(0);
const [pendingSteering, setPendingSteering] = useState<PendingSteeringInfo[]>([]);
const [error, setError] = useState<string | null>(null);
const [pendingTick, setPendingTick] = useState(0);
const [goal, setGoal] = useState<GoalBannerState | null>(null);
@@ -163,6 +170,7 @@ export function useSessionStream(
controllerRef.current = null;
setTaskState("idle");
setQueuedFollowUps(0);
setPendingSteering([]);
setLoading(false);
setError(null);
setGoal(null);
@@ -178,6 +186,7 @@ export function useSessionStream(
setTaskState(initialStatus);
setGoal(null);
setQueuedFollowUps(0);
setPendingSteering([]);
setPendingTick((t) => t + 1);
// Restore an in-flight goal's banner (only when still active — a long-finished goal
@@ -203,6 +212,7 @@ export function useSessionStream(
loadMessages: () => getMessages(sessionId),
onTaskState: setTaskState,
onQueuedFollowUps: setQueuedFollowUps,
onPendingSteering: setPendingSteering,
onLoading: setLoading,
onError: setError,
onModelChange: bump,
@@ -276,6 +286,7 @@ export function useSessionStream(
loading,
taskState,
queuedFollowUps,
pendingSteering,
lastAuthFailureMs: (controllerRef.current?.model ?? placeholderRef.current).lastAuthFailureMs,
dismissModelAuthDead,
pendingApprovals: controllerRef.current?.pendingApprovals ?? EMPTY_PENDING,
@@ -34,6 +34,7 @@ import type { OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core/omn
import type {
GoalServerEvent,
MessagesLiveTail,
PendingSteeringInfo,
ServerEvent,
SessionStatus,
} from "@prismshadow/penguin-server/api";
@@ -88,6 +89,8 @@ export interface StreamControllerDeps {
onTaskState: (state: SessionStatus) => void;
/** Queued follow-up count carried on task_state events (absent on old servers -> 0). */
onQueuedFollowUps?: (count: number) => void;
/** Undelivered steering messages carried on task_state events (absent = none): keeps the composer's "steering queued" hint alive across reloads. */
onPendingSteering?: (items: PendingSteeringInfo[]) => void;
onLoading: (loading: boolean) => void;
/** History load failure message (null = clear). */
onError: (message: string | null) => void;
@@ -190,6 +193,7 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
streamStatus = ev.state;
deps.onTaskState(ev.state);
deps.onQueuedFollowUps?.(ev.queued ?? 0);
deps.onPendingSteering?.(ev.pendingSteering ?? []);
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);
@@ -379,6 +383,7 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
streamStatus = ev.state;
deps.onTaskState(ev.state);
deps.onQueuedFollowUps?.(ev.queued ?? 0);
deps.onPendingSteering?.(ev.pendingSteering ?? []);
}
buffer.push({ kind: "server", ev, id: eventId });
return;
+4
View File
@@ -708,6 +708,9 @@ Scenarios:
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",
/** Same hint, with the queued message's content (from the server's undelivered-steering mirror; survives reloads). */
steerQueuedItem: (content: string) =>
`Steering queued — delivered with the next turn: ${content}`,
/** Label of the [user_steering] chip (a mid-run user message delivered between turns). */
userSteering: "User steering",
/** Mid-run send-mode setting: steer (delivered mid-run) vs follow-up (queued until the run ends). */
@@ -797,6 +800,7 @@ Scenarios:
openWorkspace: "Open workspace",
openAgents: "Agents panel",
filesInMessage: (n: number) => `${n} ${n === 1 ? "file" : "files"}`,
imagesInMessage: (n: number) => `${n} ${n === 1 ? "image" : "images"}`,
openPreview: "Click to preview",
showMoreFiles: (n: number) => `Show ${n} more ${n === 1 ? "file" : "files"}`,
showLess: "Show less",
+3
View File
@@ -691,6 +691,8 @@ Benchmark:
steerSend: "发送给运行中的 Agent",
/** Queued hint shown after a successful steer, until the steering message appears in the stream. */
steerQueuedIndicator: "插话已排队,将随下一轮送达",
/** Same hint, with the queued message's content (from the server's undelivered-steering mirror; survives reloads). */
steerQueuedItem: (content: string) => `插话已排队,将随下一轮送达:${content}`,
/** Label of the [user_steering] chip (a mid-run user message delivered between turns). */
userSteering: "用户插话",
/** Mid-run send-mode setting: steer (delivered mid-run) vs follow-up (queued until the run ends). */
@@ -778,6 +780,7 @@ Benchmark:
openAgents: "智能体面板",
/** File summary card at the end of a message (Codex-style): title, inline preview action, and collapsed row. */
filesInMessage: (n: number) => `${n} 个文件`,
imagesInMessage: (n: number) => `${n} 张图片`,
openPreview: "点击预览",
showMoreFiles: (n: number) => `显示其余 ${n} 个文件`,
showLess: "收起",
+7 -3
View File
@@ -24,23 +24,27 @@ const BASE: MidRunComposerState = {
hasPendingModel: false,
hasText: true,
hasImages: false,
hasFiles: false,
hasContent: true,
};
const act = (over: Partial<MidRunComposerState> = {}) => midRunAction({ ...BASE, ...over });
/** The draft shapes that carry nothing at all. */
const EMPTY = { hasText: false, hasImages: false, hasContent: false } as const;
const EMPTY = { hasText: false, hasImages: false, hasFiles: false, hasContent: false } as const;
describe("midRunAction — steering, the preferred channel", () => {
it("takes text, images, or an image with no caption at all", () => {
it("takes text, images, files, or any of them with no caption at all", () => {
expect(act()).toBe("steer");
expect(act({ hasText: false, hasImages: true })).toBe("steer");
expect(act({ hasText: true, hasImages: true })).toBe("steer");
// A file-only draft steers exactly like an image-only one (#140) — no silent fallback
// to the queue, which used to answer it with the other channel's hint.
expect(act({ hasText: false, hasFiles: true })).toBe("steer");
});
it("is skipped for a draft it cannot carry, which the queue then takes", () => {
// Skills or file attachments only: hasContent without text or images of its own.
// Skills only: hasContent without text, images or files of its own.
expect(act({ ...EMPTY, hasContent: true })).toBe("queue");
// A staged switch chip: the text belongs to the conversation that switch is about to open.
expect(act({ hasHandoffTarget: true, stagedRoute: "handoff" })).toBe("queue");
+23 -1
View File
@@ -17,7 +17,12 @@ import {
withOrigin,
} from "@prismshadow/penguin-core/omnimessage";
import type { OmniMessage, TokenCounts } from "@prismshadow/penguin-core/omnimessage";
import type { MessagesLiveTail, ServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
import type {
MessagesLiveTail,
PendingSteeringInfo,
ServerEvent,
SessionStatus,
} from "@prismshadow/penguin-server/api";
import { createStreamController } from "../src/lib/omni/stream-controller";
import type { StreamController } from "../src/lib/omni/stream-controller";
import { approvalKey, findToolCard } from "../src/lib/omni/stream-model";
@@ -38,6 +43,7 @@ const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
interface Harness {
controller: StreamController;
states: SessionStatus[];
pendingSteering: PendingSteeringInfo[][];
errors: Array<string | null>;
loadings: boolean[];
loadCalls: () => number;
@@ -59,6 +65,7 @@ function createHarness(): Harness {
reject: (e: unknown) => void;
}> = [];
const states: SessionStatus[] = [];
const pendingSteering: PendingSteeringInfo[][] = [];
const errors: Array<string | null> = [];
const loadings: boolean[] = [];
let calls = 0;
@@ -73,6 +80,7 @@ function createHarness(): Harness {
pendingLoads.push({ resolve, reject });
}),
onTaskState: (s) => states.push(s),
onPendingSteering: (items) => pendingSteering.push(items),
onLoading: (l) => loadings.push(l),
onError: (e) => errors.push(e),
onModelChange: () => {},
@@ -82,6 +90,7 @@ function createHarness(): Harness {
return {
controller,
states,
pendingSteering,
errors,
loadings,
loadCalls: () => calls,
@@ -173,6 +182,19 @@ describe("in-stream task_state is the authoritative running state (history-closi
expect(h.states).toEqual(["running"]);
});
it("reports the pending-steering mirror from task_state, and its absence as empty", async () => {
const h = createHarness();
void h.controller.load();
h.controller.handleServer({
type: "task_state",
state: "running",
pendingSteering: [{ text: "hold on", images: 0, files: 1 }],
});
// A later event without the field means "none left" — reported as empty, not skipped.
h.controller.handleServer({ type: "task_state", state: "running" });
expect(h.pendingSteering).toEqual([[{ text: "hold on", images: 0, files: 1 }], []]);
});
it("closes the current Task before an auto-started queued follow-up begins", async () => {
const h = createHarness();
const p = h.controller.load();