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 ZhengandClaude Fable 5 authored and GitHub committed 2026-08-03 15:35:32 +08:00
1 parent 835dfb5c5a
commit a0968496b7
17 files changed
+554 -68

No files matched your search

+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();