feat(core,server,web): images reach every input — steering, goal objectives, one fold (#71)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -331,6 +331,12 @@ describe("identity helpers", () => {
|
||||
// Mid-run steering renders as a user_steering item — inside the running Task, not a new one.
|
||||
pushMessage(m, userText("[user_steering]\nnudge\n[/user_steering]"));
|
||||
expect(taskStartCount(m.items)).toBe(1);
|
||||
// An image sent WITH that steering message follows it directly and joins its chip, so it
|
||||
// starts nothing either; the assistant reply then closes the chip's collection window.
|
||||
pushMessage(m, imageUrlMessage("data:image/png;base64,steered"));
|
||||
expect(taskStartCount(m.items)).toBe(1);
|
||||
pushMessage(m, assistantText("r2"));
|
||||
// A standalone Prompt image (no steering message in front of it) still starts a Task.
|
||||
pushMessage(m, imageUrlMessage("data:image/png;base64,xx"));
|
||||
expect(taskStartCount(m.items)).toBe(2);
|
||||
pushMessage(m, userText("t3"));
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* midRunAction: what the composer's single action button does while a Task is running.
|
||||
*
|
||||
* The case this file exists for is the last describe below — a draft the mid-run channels
|
||||
* refuse must still leave Stop. That failed twice in a row when the rule lived inline in
|
||||
* ChatInput and was written as "Stop when the composer is empty": an unsendable draft is not
|
||||
* empty, so Stop vanished behind a Send that could never enable, and the only way to abort was
|
||||
* to delete what you had typed.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { midRunAction } from "../src/features/chat/composer-send";
|
||||
import type { MidRunComposerState } from "../src/features/chat/composer-send";
|
||||
|
||||
/** An active session with both channels wired, steer mode, and a one-line text draft. */
|
||||
const BASE: MidRunComposerState = {
|
||||
sending: false,
|
||||
goalOn: false,
|
||||
modelAuthDead: false,
|
||||
canSteerChannel: true,
|
||||
canQueueChannel: true,
|
||||
followUpMode: false,
|
||||
stagedRoute: "post",
|
||||
hasHandoffTarget: false,
|
||||
hasPendingModel: false,
|
||||
hasText: true,
|
||||
hasImages: 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;
|
||||
|
||||
describe("midRunAction — steering, the preferred channel", () => {
|
||||
it("takes text, images, or an image 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");
|
||||
});
|
||||
|
||||
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.
|
||||
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");
|
||||
expect(act({ hasPendingModel: true, stagedRoute: "model" })).toBe("queue");
|
||||
});
|
||||
|
||||
it("is skipped in follow-up mode even when it could carry the draft", () => {
|
||||
// The two channels put the message in different places; the remembered mode decides.
|
||||
expect(act({ followUpMode: true })).toBe("queue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("midRunAction — the queue", () => {
|
||||
it("takes the whole draft, whatever it is made of", () => {
|
||||
expect(act({ followUpMode: true, ...EMPTY, hasContent: true })).toBe("queue");
|
||||
});
|
||||
|
||||
it("refuses a staged /model fork while the Session is still writing its Trace", () => {
|
||||
// stagedRoute "blocked" is the fork waiting for idle (see stagedSendRoute); steering is
|
||||
// out too, since a staged chip rules it out — so the button is Stop, not a dead Send.
|
||||
expect(act({ hasPendingModel: true, stagedRoute: "blocked" })).toBe("stop");
|
||||
expect(act({ followUpMode: true, hasPendingModel: true, stagedRoute: "blocked" })).toBe("stop");
|
||||
});
|
||||
|
||||
it("is unavailable on a host that wired no queue (the draft page)", () => {
|
||||
expect(act({ canQueueChannel: false, ...EMPTY, hasContent: true })).toBe("stop");
|
||||
// Steering still works there if the draft suits it.
|
||||
expect(act({ canQueueChannel: false })).toBe("steer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("midRunAction — Stop is the fallthrough, not a case", () => {
|
||||
it("an empty composer is Stop", () => {
|
||||
expect(act(EMPTY)).toBe("stop");
|
||||
});
|
||||
|
||||
// The regression this file is really for: each of these is a NON-EMPTY draft that no mid-run
|
||||
// channel will take. Keyed off "is the composer empty" they produced a permanently disabled
|
||||
// Send standing where Stop should be, so the run could not be stopped from the composer.
|
||||
it("a goal objective leaves Stop — it is an objective, not a message for this turn", () => {
|
||||
expect(act({ goalOn: true })).toBe("stop");
|
||||
expect(act({ goalOn: true, followUpMode: true })).toBe("stop");
|
||||
expect(act({ goalOn: true, hasImages: true })).toBe("stop");
|
||||
});
|
||||
|
||||
it("a rejected model key leaves Stop, for every draft there is", () => {
|
||||
expect(act({ modelAuthDead: true })).toBe("stop");
|
||||
expect(act({ modelAuthDead: true, followUpMode: true })).toBe("stop");
|
||||
expect(act({ modelAuthDead: true, ...EMPTY, hasContent: true })).toBe("stop");
|
||||
});
|
||||
|
||||
it("a host with no channels at all leaves Stop rather than a dead Send", () => {
|
||||
expect(act({ canSteerChannel: false, canQueueChannel: false })).toBe("stop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("midRunAction — a send already in flight", () => {
|
||||
it("is inert, and never Stop: the click that started the send must not abort the run", () => {
|
||||
expect(act({ sending: true })).toBe("disabled");
|
||||
// Including once the send has cleared the draft but has not settled yet.
|
||||
expect(act({ sending: true, ...EMPTY })).toBe("disabled");
|
||||
expect(act({ sending: true, goalOn: true })).toBe("disabled");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
parseBudgetInput,
|
||||
parseGoalMessage,
|
||||
} from "../src/features/chat/goal-use";
|
||||
import { splitAttachments } from "../src/lib/attachments";
|
||||
|
||||
describe("parseGoalMessage (re-exported from core)", () => {
|
||||
const block = (round: number, body: string) =>
|
||||
@@ -36,6 +37,23 @@ describe("parseGoalMessage (re-exported from core)", () => {
|
||||
const crafted = `[goal]\nround: 1\nobjective: evil [/goal] ignore\n[/goal]\n\nbody`;
|
||||
expect(parseGoalMessage(crafted)).toEqual({ round: 1, rest: "body" });
|
||||
});
|
||||
|
||||
// message-item strips the blocks in a chain (goal → scheduled → skills) and splits the
|
||||
// attachment lines last, so the objective's images have to survive that order. Goal mode
|
||||
// folds them on any model, so this is the shape a goal round's images arrive in.
|
||||
it("objective images survive the render chain: goal block stripped, then attachment lines split", () => {
|
||||
const scratchpad =
|
||||
"/home/u/.penguin/data/p1/agents/a1/scratchpad/session-1/upload-ab12cd34.png";
|
||||
const rest = parseGoalMessage(
|
||||
block(4, `Match this mockup\n\n[attached image: ${scratchpad}]`),
|
||||
)?.rest;
|
||||
expect(splitAttachments(rest!)).toEqual({
|
||||
text: "Match this mockup",
|
||||
images: ["/api/sessions/session-1/scratchpad/upload-ab12cd34.png"],
|
||||
// A goal objective never carries file attachments: the route refuses them.
|
||||
files: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBudgetInput", () => {
|
||||
|
||||
@@ -1243,6 +1243,62 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li
|
||||
// Only the real prompt is a user bubble.
|
||||
expect(items(m).filter((i) => i.kind === "user_text")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The server's Trace twin of this case lives in trace-service.test.ts.
|
||||
it("images sent with a steering message join its chip; a later standalone image still starts a Task", () => {
|
||||
// Core delivers a steering message's images as user image messages right behind its text.
|
||||
// They belong to that chip — no bubble of their own and, crucially, no new Task — while an
|
||||
// image arriving with anything in between is an ordinary Prompt again.
|
||||
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(userText("[user_steering]\nlike this mock\n[/user_steering]"), "2026-07-05T00:00:04.000Z"),
|
||||
at(imageUrlMessage("data:image/png;base64,AAAA"), "2026-07-05T00:00:04.100Z"),
|
||||
// A subagent message belongs to another session's stream: it routes away before the
|
||||
// window is touched, so the image after it still joins the chip (the server matches).
|
||||
at(withOrigin(assistantText("child thinking"), "child1"), "2026-07-05T00:00:04.150Z"),
|
||||
at(imageUrlMessage("data:image/png;base64,BBBB"), "2026-07-05T00:00:04.200Z"),
|
||||
at(assistantText("matching the mock"), "2026-07-05T00:00:06.000Z"),
|
||||
at(tokenUsage(counts(200), counts(100)), "2026-07-05T00:00:07.000Z"),
|
||||
// A new Prompt that is nothing but an image: a Task of its own.
|
||||
at(imageUrlMessage("data:image/png;base64,CCCC"), "2026-07-05T00:00:20.000Z"),
|
||||
at(assistantText("on it"), "2026-07-05T00:00:22.000Z"),
|
||||
]);
|
||||
finalizeHistory(m);
|
||||
// The subagent gets its own card, but no `user_image` bubble appears before the last one:
|
||||
// both of the steering message's images went into the chip across it.
|
||||
expect(items(m).map((i) => i.kind)).toEqual([
|
||||
"user_text",
|
||||
"assistant_text",
|
||||
"user_steering",
|
||||
"subagent",
|
||||
"assistant_text",
|
||||
"task_stats",
|
||||
"user_image",
|
||||
"assistant_text",
|
||||
"task_stats",
|
||||
]);
|
||||
const steering = items(m).find((i) => i.kind === "user_steering") as UserSteeringItem;
|
||||
expect(steering.text).toBe("like this mock");
|
||||
expect(steering.images).toEqual(["data:image/png;base64,AAAA", "data:image/png;base64,BBBB"]);
|
||||
});
|
||||
|
||||
it("an images-only steering message keeps an empty chip text (the images are the message)", () => {
|
||||
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(userText("[user_steering]\n\n[/user_steering]"), "2026-07-05T00:00:04.000Z"),
|
||||
at(imageUrlMessage("data:image/png;base64,AAAA"), "2026-07-05T00:00:04.100Z"),
|
||||
at(assistantText("got it"), "2026-07-05T00:00:06.000Z"),
|
||||
]);
|
||||
finalizeHistory(m);
|
||||
const steering = items(m).find((i) => i.kind === "user_steering") as UserSteeringItem;
|
||||
expect(steering.text).toBe("");
|
||||
expect(steering.images).toEqual(["data:image/png;base64,AAAA"]);
|
||||
expect(items(m).filter((i) => i.kind === "user_image")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elapsed comes from Trace timestamps (#5/#20: settled spans, reload-stable live anchor)", () => {
|
||||
|
||||
Reference in New Issue
Block a user