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

@@ -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