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(