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:
1 parent
de6380f278
commit
97852f43b8
40 files changed
+1474
-254
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user