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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user