feat(web): input history recall, sticky work-group header, chat outline (#164)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c4bf673309
commit
93c11e5557
16 files changed
+1322
-15
No files matched your search
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* outline-model.ts unit tests: turn segmentation into outline entries (merge of adjacent
|
||||
* user items, banner/goal-round handling, answer accumulation) and the plain-text preview
|
||||
* reduction.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatItem } from "../src/lib/omni/stream-model";
|
||||
import { handoffMessage } from "../src/features/chat/agent-handoff";
|
||||
import { buildScheduledMessage } from "@prismshadow/penguin-core/markers";
|
||||
import { buildOutline, previewText } from "../src/features/chat/outline-model";
|
||||
|
||||
let nextId = 0;
|
||||
const user = (text: string): ChatItem => ({ kind: "user_text", id: nextId++, text });
|
||||
const image = (): ChatItem => ({ kind: "user_image", id: nextId++, imageUrl: "blob:x" });
|
||||
const steering = (text: string): ChatItem => ({ kind: "user_steering", id: nextId++, text });
|
||||
const assistant = (text: string, streaming = false): ChatItem => ({
|
||||
kind: "assistant_text",
|
||||
id: nextId++,
|
||||
text,
|
||||
streaming,
|
||||
});
|
||||
const stats = (): ChatItem => ({
|
||||
kind: "task_stats",
|
||||
id: nextId++,
|
||||
stats: null,
|
||||
assistantText: "",
|
||||
});
|
||||
|
||||
describe("buildOutline", () => {
|
||||
it("opens one entry per exchange and accumulates that turn's assistant text", () => {
|
||||
const items: ChatItem[] = [
|
||||
user("question A"),
|
||||
assistant("part one."),
|
||||
assistant("part two."),
|
||||
stats(),
|
||||
user("question B"),
|
||||
assistant("reply B"),
|
||||
];
|
||||
const outline = buildOutline(items);
|
||||
expect(outline).toHaveLength(2);
|
||||
expect(outline[0]).toMatchObject({ question: "question A", answer: "part one. part two." });
|
||||
expect(outline[1]).toMatchObject({ question: "question B", answer: "reply B" });
|
||||
expect(outline[0]!.anchorId).toBe((items[0] as { id: number }).id);
|
||||
});
|
||||
|
||||
it("merges adjacent user items into one entry and labels image-only prompts with an empty question", () => {
|
||||
const items: ChatItem[] = [image(), user("text after images"), assistant("ok"), image()];
|
||||
const outline = buildOutline(items);
|
||||
expect(outline).toHaveLength(2);
|
||||
expect(outline[0]).toMatchObject({ question: "text after images", answer: "ok" });
|
||||
expect(outline[1]).toMatchObject({ question: "", answer: "" });
|
||||
});
|
||||
|
||||
it("banner-only texts open no entry but separate user runs; steering stays inside the turn", () => {
|
||||
const banner = handoffMessage({ agentId: "a", sessionId: "s", workspace: "/w" });
|
||||
const items: ChatItem[] = [
|
||||
user(banner),
|
||||
user("real question"),
|
||||
steering("mid-run note"),
|
||||
assistant("answer"),
|
||||
];
|
||||
const outline = buildOutline(items);
|
||||
expect(outline).toHaveLength(1);
|
||||
expect(outline[0]).toMatchObject({ question: "real question", answer: "answer" });
|
||||
});
|
||||
|
||||
it("keeps one entry per goal run (later rounds merge into round 1) and includes scheduled turns", () => {
|
||||
const round = (n: number) => `[goal]\nround: ${n}\nobjective: o\n[/goal]\ndo the thing`;
|
||||
const items: ChatItem[] = [
|
||||
user(round(1)),
|
||||
assistant("round 1 reply"),
|
||||
user(round(2)),
|
||||
assistant("round 2 reply"),
|
||||
user(buildScheduledMessage("nightly", "2026-08-01T00:00:00Z", "scheduled prompt")),
|
||||
assistant("scheduled reply"),
|
||||
];
|
||||
const outline = buildOutline(items);
|
||||
expect(outline).toHaveLength(2);
|
||||
expect(outline[0]).toMatchObject({
|
||||
question: "do the thing",
|
||||
answer: "round 1 reply round 2 reply",
|
||||
});
|
||||
expect(outline[1]).toMatchObject({ question: "scheduled prompt", answer: "scheduled reply" });
|
||||
});
|
||||
|
||||
it("caps answer accumulation", () => {
|
||||
const items: ChatItem[] = [user("q"), assistant("x".repeat(400)), assistant("y".repeat(400))];
|
||||
const outline = buildOutline(items);
|
||||
expect(outline[0]!.answer.length).toBeLessThanOrEqual(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewText", () => {
|
||||
it("flattens markdown to one plain line", () => {
|
||||
const md =
|
||||
"# Title\n\nSome **bold** and `code`, a [link](https://x) and\n\n```ts\nconst a = 1\n```\n- item";
|
||||
expect(previewText(md, 200)).toBe("Title Some bold and code, a link and const a = 1 item");
|
||||
});
|
||||
|
||||
it("truncates with an ellipsis at the cap", () => {
|
||||
expect(previewText("hello world", 5)).toBe("hello…");
|
||||
expect(previewText("hello", 5)).toBe("hello");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user