feat(chat): terminal chat parity for every tool; Desktop-like artifacts

- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
This commit is contained in:
2026-10-21 15:44:17 +07:00
parent aee4fc190e
commit 383b9ae231
41 changed files with 2582 additions and 426 deletions
@@ -0,0 +1,146 @@
/**
* @file antigravity-transcript.test.js
* @description An Antigravity (agy) session's conversation view: its
* `transcript_full.jsonl` step log mapped to the Claude transcript shape —
* the prompt out of `<USER_REQUEST>`, the model's thinking, text, tool calls
* and tokens, each tool result tied to its call by step and an invalid call
* marked an error — one "main" transcript, no subagent files, and a session
* id that is no UUID finding nothing. The agy dir and the DB are temp dirs;
* the steps are synthetic, in the shape agy 1.4 writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-transcript-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const agy = require("../lib/antigravity-usage");
let server;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const ID = "57154e1a-65e5-4b51-a116-1890f5ca831f";
const AT = "2026-10-21T05:08:24Z";
function steps(id) {
const dir = path.join(
process.env.AGENTHUB_GEMINI_DIR,
"antigravity-cli",
"brain",
id,
".system_generated",
"logs"
);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "transcript_full.jsonl");
const lines = [
{
step_index: 0,
source: "USER_EXPLICIT",
type: "USER_INPUT",
status: "DONE",
created_at: AT,
content:
"<USER_REQUEST>\nCreate c.txt\n</USER_REQUEST>\n<ADDITIONAL_METADATA>\nThe current local time is: x.\n</ADDITIONAL_METADATA>",
},
{
step_index: 1,
source: "MODEL",
type: "PLANNER_RESPONSE",
status: "DONE",
created_at: AT,
input_tokens: 3889,
cache_read_tokens: 768,
output_tokens: 12,
thinking: "Two commands.",
content: "Running them now.",
tool_calls: [
{ name: "run_command", args: { CommandLine: "echo ok > c.txt" } },
{ name: "read", args: { path: "c.txt" } },
],
},
{
step_index: 2,
source: "MODEL",
type: "GENERIC",
status: "DONE",
created_at: AT,
content: "Created At: x\nCompleted At: y\n\nThe command exited with code 0.\n",
},
{
step_index: 3,
source: "MODEL",
type: "GENERIC",
status: "INVALID",
error: 'unknown tool: "read"',
created_at: AT,
content: 'Created At: x\nCompleted At: y\nError invalid tool call: unknown tool: "read"',
},
{
step_index: 4,
source: "MODEL",
type: "PLANNER_RESPONSE",
status: "DONE",
created_at: AT,
content: "Done.",
},
];
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
return file;
}
describe("an Antigravity session's conversation", () => {
it("reads agy's step log in the Claude transcript shape", async () => {
const file = steps(ID);
assert.equal(agy.transcriptPath(ID), file);
assert.equal(agy.transcriptPath("../../etc"), null);
db.prepare(
"INSERT INTO sessions (id, name, status, started_at, tool) VALUES (?, 'c', 'completed', ?, 'antigravity')"
).run(ID, AT);
const { createApp, startServer } = require("../index");
server ||= await startServer(createApp(), 0);
const BASE = `http://127.0.0.1:${server.address().port}`;
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type]),
[["main", "main"]]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
[
["user", ["text"]],
["assistant", ["thinking", "text", "tool_use", "tool_use"]],
["tool", ["tool_result"]],
["tool", ["tool_result"]],
["assistant", ["text"]],
]
);
const [user, plan, ok, bad] = body.messages;
assert.equal(user.content[0].text, "Create c.txt");
assert.equal(user.timestamp, AT);
// Each result names the call it answers: call i of step s lands at step s+1+i.
assert.deepEqual(
plan.content.filter((c) => c.type === "tool_use").map((c) => c.id),
[ok.content[0].id, bad.content[0].id]
);
assert.equal(ok.content[0].output, "The command exited with code 0.\n");
assert.equal(ok.content[0].is_error, false);
assert.equal(bad.content[0].is_error, true);
assert.equal(plan.usage.input_tokens, 3889);
assert.equal(plan.usage.cache_read_input_tokens, 768);
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
assert.equal(sub.messages.length, 0);
});
});
+72 -4
View File
@@ -4,8 +4,10 @@
* project slug walked back onto the filesystem) and no tokens, an unchanged
* file never re-read, a quiet one settled to completed, a session the user
* deleted staying deleted, and a non-cursor row with the same id never
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
* synthetic, in the shape Cursor writes.
* touched, a session claimed by the Cursor pane it started in, and the
* transcript mapped to the Claude transcript shape by the
* transcript routes. The Cursor dir and the DB are temp dirs; the transcripts
* are synthetic, in the shape Cursor writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,13 +18,18 @@ const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const cursor = require("../lib/cursor-usage");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
let server;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
@@ -65,7 +72,8 @@ const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id
beforeEach(() => {
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
db.exec(
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;" +
" DELETE FROM dashboard_runs; DELETE FROM lanes;"
);
});
@@ -175,4 +183,64 @@ describe("cursor usage", () => {
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
assert.equal(session(ID).cwd, null);
});
it("maps the transcript to the transcript shape", async () => {
const file = transcript(ID);
const more = [
{
role: "assistant",
message: {
content: [
{ type: "text", text: "Reading it now." },
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "x" } },
],
},
},
{
role: "user",
message: { content: [{ type: "tool_result", tool_use_id: "t1", content: "no match" }] },
},
{ role: "assistant", message: { content: [{ type: "text", text: "Done." }] } },
];
fs.appendFileSync(file, more.map((l) => JSON.stringify(l)).join("\n") + "\n");
await cursor.ingestCursorUsage();
assert.equal(cursor.transcriptPath(ID), file);
const { createApp, startServer } = require("../index");
server ||= await startServer(createApp(), 0);
const BASE = `http://127.0.0.1:${server.address().port}`;
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type]),
[["main", "main"]]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
[
["user", ["text"]],
["assistant", ["tool_use"]],
["assistant", ["text", "tool_use"]],
["tool", ["tool_result"]],
["assistant", ["text"]],
]
);
assert.equal(body.messages[0].content[0].text, "Review the campaign code\nplease");
assert.equal(body.messages[3].content[0].output, "no match");
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
assert.equal(sub.messages.length, 0);
});
it("gives the session to the Cursor pane it started in", async () => {
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (5, 'w', ?)").run(PROJECT);
db.prepare(
`INSERT INTO dashboard_runs (id, lane_id, cwd, status, started_at, tool)
VALUES ('agenthub-lane-5', 5, ?, 'running', ?, 'cursor')`
).run(PROJECT, new Date(Date.now() - 60_000).toISOString());
transcript(ID);
await cursor.ingestCursorUsage();
assert.equal(session(ID).lane_id, 5);
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-5'");
assert.equal(run.get().session_id, ID);
});
});
+3
View File
@@ -205,6 +205,9 @@ describe("lane-sources instructions and launch", () => {
assert.equal(argv[5], "--permission-mode");
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
// Claude Code hides its task tools from newer models without this, and the
// chat Progress panel is drawn from those calls.
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
assert.match(text, /^- always answer in Vietnamese/);
+3
View File
@@ -227,6 +227,9 @@ describe("broadcastAudience", () => {
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
// A transcript's growth notice goes where the session's events go.
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m2), true);
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
});
it("routes workspace messages by owner and keeps unknown types from members", () => {