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-Yu
2026-07-30 16:50:35 +08:00
committed by GitHub
parent de6380f278
commit 97852f43b8
40 changed files with 1474 additions and 254 deletions
+9 -1
View File
@@ -674,8 +674,16 @@ export interface TaskCreateResponse {
* task POST).
*/
export interface SteerRequest {
/** Non-empty message text (trimmed server-side). */
/** Message text (trimmed server-side); may be empty when `images` 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.
*/
images?: string[];
}
export interface ApprovalDecisionRequest {
+70 -22
View File
@@ -79,6 +79,36 @@ const SESSION_CATEGORIES: readonly SessionCategory[] = [
"archived",
];
/**
* A base64 `data:` URL of an image, in the exact shape core parses it back out of
* (`imagesToScratchpadPaths`): one mime type, the `;base64,` marker, a non-empty base64 body.
* The mime is deliberately unconstrained — core maps the ones it knows to a file extension and
* falls back to `.bin`, and the image tools sniff the magic bytes rather than trusting either.
*
* Checking the body, not just the `data:` prefix, is what keeps the failure here instead of
* three layers down: core turns a data URL it cannot parse into an "[an attached image could
* not be saved and was dropped]" line, which for an HTTP caller means a 202 followed by a
* message quietly missing its picture. The file-attachment field has always validated its own
* payload this way (parseAttachmentPart); this is the same rule for images.
*/
const IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
/**
* The image-URL rule every image-carrying request field obeys: a `data:` URL the session
* keeps (inline, or written to the scratchpad without vision) or an http(s) URL it references.
* `field` names the offending value in the error, so each caller reads as if it validated
* inline.
*/
function requireImageUrl(url: unknown, field: string): string {
if (typeof url === "string") {
if (url.startsWith("http://") || url.startsWith("https://")) return url;
if (IMAGE_DATA_URL.test(url)) return url;
}
throw badRequest(
`${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`,
);
}
/**
* Resolve a scratchpad file name to an absolute path inside `dir`, or null when it could point
* anywhere else (the caller turns that into the same 404 a missing file gets, so a probe learns
@@ -133,14 +163,7 @@ function parseTaskInput(body: Record<string, unknown>): ParsedTaskInput {
return;
}
if (part.type === "image_url") {
const url = part.imageUrl;
if (
typeof url !== "string" ||
!(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))
) {
throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
}
messages.push(imageUrlMessage(url));
messages.push(imageUrlMessage(requireImageUrl(part.imageUrl, `input[${i}].imageUrl`)));
return;
}
if (part.type === "file") {
@@ -157,6 +180,17 @@ function parseTaskInput(body: Record<string, unknown>): ParsedTaskInput {
return { messages, attachments };
}
/**
* Validate the optional `images` field of a steer request: a list of `data:` / http(s) URLs
* (same rule as a task input's `imageUrl`), absent or empty = a text-only steering message.
*/
function parseSteerImages(body: Record<string, unknown>): string[] {
const images = body.images;
if (images === undefined) return [];
if (!Array.isArray(images)) throw badRequest("images must be an array.");
return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
}
/**
* 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).
@@ -491,21 +525,23 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
// follow-up keeps its level for its auto-start.
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
if (goal) {
// Goal mode: the input must be plain non-empty text (its marker-stripped text becomes
// the objective, re-injected every round — images and file attachments have no place in
// the protocol; rejected before any upload is written to disk).
// Goal mode: the input needs non-empty text, since its marker-stripped text becomes the
// objective that every round re-injects and an image on its own doesn't say what the
// goal is. Images can come along — core folds them into `[attached image: <path>]` lines
// inside the objective (whatever the model's vision) so they survive the rounds. File
// attachments cannot: nothing folds them into the objective, so they are turned away
// here, before any upload is written to disk.
const { messages, attachments } = parseTaskInput(body);
const text = messages
.filter((m) => (m.payload as { type?: string }).type === "text")
.map((m) => (m.payload as { text: string }).text)
.join("\n")
.trim();
if (
!text ||
attachments.length > 0 ||
messages.some((m) => (m.payload as { type?: string }).type !== "text")
) {
throw badRequest("goal mode requires text-only input (the objective).");
if (!text) {
throw badRequest("goal mode requires a non-empty text objective.");
}
if (attachments.length > 0) {
throw badRequest("goal mode accepts text and images only (no file attachments).");
}
const { sessionId } = await deps.manager.startGoal(row.sessionId, {
input: messages,
@@ -552,15 +588,27 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
});
// Mid-run steering: queue a user message for the running Task; core delivers it between
// turns as a standalone `[user_steering]` user message (the model sees it without the loop
// being interrupted). 409 not_running when no Task is in progress — the frontend then falls
// back to a normal task POST.
// turns as a standalone `[user_steering]` user message, with any images following it as
// user image messages (the model sees the whole thing without the loop being interrupted).
// 409 not_running when no Task is in progress — the frontend then falls back to a normal
// task POST.
app.post("/:sessionId/steer", async (c) => {
const row = resolveSession(c);
const body = await readJson(c);
const text = typeof body.text === "string" ? body.text.trim() : "";
if (!text) throw badRequest("text must be a non-empty string.");
deps.manager.steer(row.sessionId, text);
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.");
}
// 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)),
]);
return c.body(null, 202);
});
+15 -10
View File
@@ -88,8 +88,8 @@ export interface RuntimeSession {
compact(opts: { signal: AbortSignal }): AsyncGenerator<OmniMessage>;
/** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */
compactability(): CompactAvailability;
/** Queues a mid-run steering message (core `Session.steer`); false when no Task is running. */
steer(text: string): boolean;
/** Queues a mid-run steering input (core `Session.steer`); false when no Task is running. */
steer(input: OmniMessage[]): boolean;
/** Skips the in-progress reconnect backoff, firing the next retry immediately (core `Session.skipReconnectWait`); false when no wait is in progress. */
skipReconnectWait(): boolean;
toolPermission(name: string): "r" | "rw" | undefined;
@@ -510,7 +510,7 @@ export class SessionManager {
async startGoal(
sessionId: string,
args: {
/** Round-1 input (text-only, route-validated); its marker-stripped text is the objective. */
/** Round-1 input (route-validated to carry text; images may ride along); its marker-stripped text is the objective. */
input: OmniMessage[];
budget: number;
/** Optional per-goal thinking level: rides every round's Task (route-validated). */
@@ -526,6 +526,10 @@ export class SessionManager {
// The objective is the user's own text (leading skill-invocation blocks stripped) —
// the same derivation core records in GOAL.yaml; used for the run-state row, the
// goal_started event, and as title material.
// `isPlainText` leaves attached images out, so this copy carries no
// `[attached image: <path>]` lines — which suits its readers, since a status card and a
// generated title read better without absolute scratchpad paths. Core keeps its own
// folded copy (Session.runGoal) for what the rounds actually re-inject.
const text = args.input
.filter(isPlainText("user"))
.map((m) => m.payload.text)
@@ -773,15 +777,16 @@ export class SessionManager {
}
/**
* Mid-run steering: forward the text to the running Session (core delivers it between
* turns as a standalone `[user_steering]` user message — no SSE event of its own; the
* message arrives through the stream the drive loop already 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.
* Mid-run steering: forward the message to the running Session (core delivers it between
* turns as a standalone `[user_steering]` user message followed by its images — no SSE
* event of its own; the messages arrive through the stream the drive loop already
* 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.
*/
steer(sessionId: string, text: string): void {
steer(sessionId: string, input: OmniMessage[]): void {
const entry = this.entries.get(sessionId);
if (!entry || entry.status !== "running" || !entry.session.steer(text)) {
if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
throw new HttpError(
409,
"not_running",
@@ -281,6 +281,8 @@ export class TraceService {
// request that resumes after compaction starts yet another Task.
let taskIndex = -1;
let continuation = false; // The previous round's Request called a tool -> the next request_begin continues the same Task
/** Inside the image run that follows a `[user_steering]` text (see the turn-start rule below). */
let steeringImages = false;
let sawToolCallThisRequest = false;
// Compaction interval (compaction_begin..compaction_end): the compaction
// request's request_begin/request_end and token_usage all fall inside it (see
@@ -360,10 +362,22 @@ export class TraceService {
typeof p.text === "string" &&
parseUserSteeringText(p.text) !== null;
if (isSteeringText) continuation = true;
// Images sent with a steering message ride immediately behind its text, exactly as a
// Prompt's images ride behind theirs — and they inherit its exclusion: still the same
// Task, so `steeringImages` keeps the window open across the whole run of them and
// anything else on the main session closes it (an images-only Prompt after a steering
// message is a genuine new turn). A subagent's messages pass through without closing it:
// they belong to another session's stream and say nothing about this one's grouping.
// The Web answers the same "what is one Task" question over the live stream — see
// `openSteering` in web/src/lib/omni/stream-model.ts; the two need to stay in step.
const isImage = !hasOrigin && msg.type === "model_msg" && p.type === "image_url";
if (!hasOrigin && !isSteeringText && !(isImage && steeringImages)) steeringImages = false;
if (isSteeringText) steeringImages = true;
const startsUserTurn =
!hasOrigin &&
!compactionActive &&
!isSteeringText &&
!steeringImages &&
msg.type === "model_msg" &&
((p.type === "text" && p.role === "user") || p.type === "image_url");
const startsCompactionTurn =
+30 -1
View File
@@ -10,6 +10,7 @@ import {
buildSkillsMessage,
emptyTokenCounts,
goalFinished,
imageUrlMessage,
tokenUsage,
userText,
} from "@prismshadow/penguin-core";
@@ -154,17 +155,20 @@ describe("SessionManager.startGoal", () => {
*/
function goalFakeSession(
stream: (input: OmniMessage[]) => OmniMessage[],
): RuntimeSession & { runOpts: RunOpts[] } {
): RuntimeSession & { runOpts: RunOpts[]; runs: OmniMessage[][] } {
const runOpts: RunOpts[] = [];
const runs: OmniMessage[][] = [];
return {
sessionId: ROW.sessionId,
runOpts,
runs,
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
steer: () => false,
skipReconnectWait: () => false,
async *run(input: OmniMessage[], opts) {
runs.push(input);
runOpts.push({
...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
...(opts.goal !== undefined ? { goal: opts.goal } : {}),
@@ -250,6 +254,31 @@ describe("SessionManager.startGoal", () => {
expect((published[0]!.payload as { text: string }).text).toContain("[use_skills]");
});
it("records the objective without the attached images: the display copy stays path-free", async () => {
// Core folds the attached images into `[attached image: …]` lines inside the objective it
// re-injects each round. The objective recorded here is the one shown to people — status
// card, goal_started, title material — so it keeps the user's words only.
const session = goalFakeSession(() => [goalFinished("complete", 1, 10)]);
const manager = makeManager(session);
const events: ChannelEvent[] = [];
channels.get(ROW.sessionId).subscribe((e) => events.push(e));
await manager.startGoal(ROW.sessionId, {
input: [userText("Match this mockup"), imageUrlMessage("data:image/png;base64,aGk=")],
budget: -1,
});
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
// The whole input still reaches core (the images included) — only the recorded copy differs.
expect(session.runs[0]).toHaveLength(2);
const started = events
.filter((e) => e.event === "server_event")
.map((e) => JSON.parse(e.data) as { type: string; objective?: string })
.find((e) => e.type === "goal_started");
expect(started?.objective).toBe("Match this mockup");
expect(goals.latestForSession(ROW.sessionId)?.objective).toBe("Match this mockup");
});
it("409s while a goal is running (mutual exclusion); runs without a goals repo", async () => {
let release: () => void = () => {};
const gate = new Promise<void>((r) => {
+38 -4
View File
@@ -7,7 +7,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { sessionMeta, userText } from "@prismshadow/penguin-core";
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
import type { OmniMessage, SessionMetaPayload } from "@prismshadow/penguin-core";
import type {
ProjectCreateResponse,
SessionCreateResponse,
@@ -594,14 +594,48 @@ describe("session-index", () => {
});
expect(bad.status).toBe(400);
}
// Image parts have no place in the re-injected objective.
const image = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
// An image alone states no goal: the objective is re-injected as text every round.
const imageOnly = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
input: [{ type: "image_url", imageUrl: "data:image/png;base64,aGk=" }],
goal: {},
});
expect(imageOnly.status).toBe(400);
// With text alongside them the images are fine: they reach the manager, and core folds
// them into the objective as path lines from there. startGoal stands in for the run so
// the assertion is about validation alone — a real goal loop would still be settling
// after the test closed its database.
const started: OmniMessage[][] = [];
t.deps.manager.startGoal = async (sessionId, args) => {
started.push(args.input);
return { sessionId };
};
const withText = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
input: [
{ type: "text", text: "objective" },
{ type: "image_url", imageUrl: "data:image/png;base64,aGk=" },
],
goal: {},
});
expect(image.status).toBe(400);
expect(withText.status).toBe(202);
expect(started[0]?.map((m) => (m.payload as { type: string }).type)).toEqual([
"text",
"image_url",
]);
// A file attachment is the one input a goal cannot take, images notwithstanding: nothing
// folds it into the objective every round re-injects, so it is refused before any upload
// is written to disk (startGoal is never reached).
const withFile = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
input: [
{ type: "text", text: "objective" },
{
type: "file",
fileName: "report.pdf",
dataUrl: `data:application/pdf;base64,${Buffer.from("PDF-BYTES").toString("base64")}`,
},
],
goal: {},
});
expect(withFile.status).toBe(400);
expect(started).toHaveLength(1);
});
});
+4 -4
View File
@@ -23,7 +23,7 @@ import {
userText,
withOrigin,
} from "@prismshadow/penguin-core";
import type { ApproveFn, OmniMessage } from "@prismshadow/penguin-core";
import type { ApproveFn, OmniMessage, TextPayload } from "@prismshadow/penguin-core";
import { openDatabase } from "../src/db/database.js";
import { HttpError } from "../src/http/errors.js";
import { SessionsRepo } from "../src/db/repos/sessions.js";
@@ -239,14 +239,14 @@ describe("session-manager", () => {
it("steer: forwards to the running session; idle / lost race → 409 not_running", async () => {
const steered: string[] = [];
const fake = approvalFakeSession("session-1");
fake.steer = (text: string) => {
steered.push(text);
fake.steer = (input: OmniMessage[]) => {
steered.push((input[0]!.payload as TextPayload).text);
return true;
};
const manager = makeManager(loaderOf(fake));
const steerErr = (text: string): unknown => {
try {
manager.steer("session-1", text);
manager.steer("session-1", [userText(text)]);
return null;
} catch (e) {
return e;
+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");
@@ -20,6 +20,7 @@ import {
toolCall,
toolCallOutput,
userText,
withOrigin,
} from "@prismshadow/penguin-core";
import type { OmniMessage, SessionMetaPayload, TokenCounts } from "@prismshadow/penguin-core";
import { TraceService } from "../src/services/trace-service.js";
@@ -661,6 +662,38 @@ describe("trace-service", () => {
expect(a.modelSegments.map((s) => s.taskIndex)).toEqual([0, 0, 0, 1]);
});
// The Web's live-stream twin of this case lives in stream-model.test.ts.
it("Task grouping: images sent with a steering message don't start a Task either (a Prompt's do)", async () => {
const T = (sec: string) => `2026-07-05T10:04:${sec}Z`;
await writeTraceFile(root, P, A, "2026-07-05", S, 13, [
sessionMeta(metaPayload()),
// Task 0: a normal turn that calls a tool.
at(T("00.000"), userText("q1")),
at(T("01.000"), requestBegin()),
at(T("02.000"), toolCall({ name: "read_file", arguments: "{}", toolCallId: "t1" })),
at(T("02.500"), requestEnd("completed")),
at(T("03.000"), toolCallOutput({ output: "o", toolCallId: "t1" })),
// Steering with two images: core delivers them right behind the text, and the whole
// batch stays inside Task 0 — an image is a turn starter everywhere except here.
at(T("03.200"), userText("[user_steering]\nlike this mock\n[/user_steering]")),
at(T("03.300"), imageUrlMessage("data:image/png;base64,AAAA")),
// A subagent message belongs to another session's stream and says nothing about this
// one's grouping, so it leaves the window open (the Web skips these even earlier).
at(T("03.350"), withOrigin(assistantText("child thinking"), "child-1")),
at(T("03.400"), imageUrlMessage("data:image/png;base64,BBBB")),
at(T("03.500"), requestBegin()),
at(T("04.000"), assistantText("answer 1")),
at(T("04.500"), requestEnd("completed")),
// An images-only Prompt after all that is a genuine new turn.
at(T("20.000"), imageUrlMessage("data:image/png;base64,CCCC")),
at(T("21.000"), requestBegin()),
at(T("22.000"), assistantText("answer 2")),
at(T("22.500"), requestEnd("completed")),
]);
const a = await service.analyze(P, A, S, 13);
expect(a.requests.map((r) => r.taskIndex)).toEqual([0, 0, 1]);
});
// Compaction is its own turn: the previous turn called a tool and would
// otherwise "continue", but compaction_begin breaks that continuation, so the
// compaction request lands on a new taskIndex. A successful compaction splits