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:
rank-YuandClaude Fable 5 authored and GitHub committed 2026-07-30 16:50:35 +08:00
1 parent de6380f278
commit 97852f43b8
40 files changed
+1474 -254

No files matched your search

+67 -8
View File
@@ -1,7 +1,7 @@
/**
* 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;
* - 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;
* - 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).
@@ -16,15 +16,23 @@ import type { TestApp } from "./helpers.js";
const SID = "session-2026-07-06-10-00-00-ccdd0001";
/** One recorded steer call: the trimmed text plus the images that rode along with it. */
/** A recorded steering input, one `text:`/`img:` line per message, in delivered order. */
const shape = (input: OmniMessage[]): string[] =>
input.map((m) => {
const p = m.payload as { type: string; text?: string; image_url?: string };
return p.type === "image_url" ? `img:${p.image_url}` : `text:${p.text}`;
});
/** Fake Session that parks on one approval (keeps the Task running) and records steer calls. */
function steeringFakeSession(sessionId: string, steered: string[]): RuntimeSession {
function steeringFakeSession(sessionId: string, steered: OmniMessage[][]): RuntimeSession {
return {
sessionId,
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
steer: (text: string) => {
steered.push(text);
steer: (input: OmniMessage[]) => {
steered.push(input);
return true;
},
skipReconnectWait: () => false,
@@ -42,7 +50,7 @@ function steeringFakeSession(sessionId: string, steered: string[]): RuntimeSessi
describe("steer route", () => {
let t: TestApp;
let api: ReturnType<typeof apiClient>;
let steered: string[];
let steered: OmniMessage[][];
beforeEach(async () => {
t = await createTestApp();
@@ -75,17 +83,68 @@ describe("steer route", () => {
expect(steered).toEqual([]);
});
it("running → 202, the trimmed text reaches the core session; empty text → 400", async () => {
it("running → 202, the trimmed text reaches the core session; a message with nothing in it → 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((await api.post(`/api/sessions/${SID}/steer`, { text: "", images: [] })).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"]);
expect(steered.map(shape)).toEqual([["text:focus on tests"]]);
t.deps.manager.decideApproval(SID, "tc-steer", "allow");
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
});
it("images ride along with the steering text — and carry it alone when there is none", async () => {
await t.deps.manager.startTask(SID, [userText("go")]);
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
const png = "data:image/png;base64,AAAA";
const captioned = await api.post(`/api/sessions/${SID}/steer`, {
text: " look at this ",
images: [png, "https://example.com/shot.png"],
});
expect(captioned.status).toBe(202);
// An image with no caption is a complete steering message: empty text is accepted here.
const bare = await api.post(`/api/sessions/${SID}/steer`, { text: "", images: [png] });
expect(bare.status).toBe(202);
// The route hands core the same message list a task input would carry — and drops the
// text message entirely when the images are the whole message, so a fold's path lines
// aren't preceded by an empty line.
expect(steered.map(shape)).toEqual([
["text:look at this", `img:${png}`, "img:https://example.com/shot.png"],
[`img:${png}`],
]);
// Same URL rule as a task input's imageUrl; a non-array images field is rejected outright.
expect(
(await api.post(`/api/sessions/${SID}/steer`, { text: "x", images: ["/etc/passwd"] })).status,
).toBe(400);
expect((await api.post(`/api/sessions/${SID}/steer`, { text: "x", images: png })).status).toBe(
400,
);
// The data: body is checked here, not left to core: a URL core cannot parse comes back as
// an "could not be saved" line inside the delivered message, which for an HTTP caller is a
// 202 and then a picture quietly missing. These are the shapes that get that far.
for (const bad of [
"data:image/png", // no ;base64, marker at all
"data:image/png;base64,", // marker, empty body
"data:image/png;base64,not base64!", // body outside the base64 alphabet
"data:,aGk=", // no mime
"data:image/png;charset=utf-8;base64,aGk=", // an extra parameter core's parse rejects
]) {
expect(
(await api.post(`/api/sessions/${SID}/steer`, { text: "x", images: [bad] })).status,
).toBe(400);
}
expect(steered).toHaveLength(2);
t.deps.manager.decideApproval(SID, "tc-steer", "allow");
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");