chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions

Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
@@ -0,0 +1,118 @@
/**
* @file MessageList.agents.test.tsx
* @description Verifies a live chat's background-agents card reads each
* running agent's own transcript (by the agentId its launch result names)
* and shows the step it is on and its context size (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageList, agentProgress } from "../MessageList";
import { api } from "../../../lib/api";
import type { TranscriptMessage } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
});
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
// The subagent's own transcript: its task, then a search still running.
const tail: TranscriptMessage[] = [
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
content: [
{
type: "tool_use",
id: "g1",
name: "Grep",
input: { pattern: "copyId", description: "Searching for copyId" },
},
],
}),
];
const main: TranscriptMessage[] = [
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
}),
m({
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
]),
},
],
}),
m({
type: "assistant",
sender: "assistant",
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting" }],
}),
];
describe("background agents' progress", () => {
const rects = Element.prototype.getClientRects;
beforeEach(() => {
// jsdom lays nothing out: make the card count as on screen.
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
vi.mocked(api.sessions.transcript).mockResolvedValue({
messages: tail,
total: 2,
has_more: false,
first_line: 1,
last_line: 2,
});
});
afterEach(() => {
Element.prototype.getClientRects = rects;
vi.mocked(api.sessions.transcript).mockReset();
});
it("reads the step an agent is on and its context", () => {
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
});
it("shows them on the agent's line, from its own transcript", async () => {
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
await waitFor(() =>
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
"Searching for copyId"
)
);
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
agent_id: "a4118367b503adecc",
limit: 20,
});
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
});
it("reads nothing without the session", () => {
render(<MessageList live loading={false} messages={main} />);
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
expect(api.sessions.transcript).not.toHaveBeenCalled();
});
});
@@ -5,12 +5,13 @@
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip.
* shows as a chip. A live chat lists the background subagents still running
* and does not call their turn done (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -343,4 +344,77 @@ describe("MessageList — sender attribution", () => {
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
describe("background subagents", () => {
const at = new Date().toISOString();
const launch = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
});
const launched = msg({
timestamp: at,
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
},
],
});
const reply = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting for the agent" }],
});
const notified = msg({
sender: "system",
timestamp: at,
content: [
{
type: "text",
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
},
],
});
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
it("lists an agent until its notification names the call", () => {
const results = (m: TranscriptMessage[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
const all = [...base, notified];
expect(backgroundAgents(all, results(all))).toEqual([]);
});
it("does not call the turn done while the agent works", () => {
const { rerender } = render(<MessageList live loading={false} messages={base} />);
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
"ExploreChecking copyId coverage"
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
});
it("shows nothing once the session is no longer live", () => {
render(<MessageList loading={false} messages={base} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
});
});
});