web: fix args-object React crash; e2e plugin-contract scenario
This commit is contained in:
+107
-90
@@ -19,14 +19,14 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { startFakeGitLab } from "./fake-gitlab.mjs";
|
import { startFakeGitLab } from "./fake-gitlab.mjs";
|
||||||
import {
|
import {
|
||||||
DAEMON_DIR,
|
DAEMON_DIR,
|
||||||
REPO_ROOT,
|
REPO_ROOT,
|
||||||
Results,
|
Results,
|
||||||
TEST_TOKEN,
|
TEST_TOKEN,
|
||||||
WEB_DIST,
|
WEB_DIST,
|
||||||
ensureWebDist,
|
ensureWebDist,
|
||||||
freePort,
|
freePort,
|
||||||
startDaemon,
|
startDaemon,
|
||||||
} from "./lib.mjs";
|
} from "./lib.mjs";
|
||||||
|
|
||||||
import { run as auth } from "./scenarios/auth.mjs";
|
import { run as auth } from "./scenarios/auth.mjs";
|
||||||
@@ -41,16 +41,16 @@ import { run as pluginContract } from "./scenarios/plugin-contract.mjs";
|
|||||||
import { run as resilience } from "./scenarios/resilience.mjs";
|
import { run as resilience } from "./scenarios/resilience.mjs";
|
||||||
|
|
||||||
const SCENARIOS = [
|
const SCENARIOS = [
|
||||||
["auth", auth],
|
["auth", auth],
|
||||||
["web-dist", webDist],
|
["web-dist", webDist],
|
||||||
["agent-lifecycle", agentLifecycle],
|
["agent-lifecycle", agentLifecycle],
|
||||||
["replay", replay],
|
["replay", replay],
|
||||||
["prompt-routing", promptRouting],
|
["prompt-routing", promptRouting],
|
||||||
["session-list", sessionList],
|
["session-list", sessionList],
|
||||||
["gitlab", gitlab],
|
["gitlab", gitlab],
|
||||||
["spawn-validation", spawnValidation],
|
["spawn-validation", spawnValidation],
|
||||||
["plugin-contract", pluginContract],
|
["plugin-contract", pluginContract],
|
||||||
["resilience", resilience], // last: SIGKILLs and reboots the daemon
|
["resilience", resilience], // last: SIGKILLs and reboots the daemon
|
||||||
];
|
];
|
||||||
|
|
||||||
const r = new Results();
|
const r = new Results();
|
||||||
@@ -60,92 +60,109 @@ let tmpDir = null;
|
|||||||
let cleanedUp = false;
|
let cleanedUp = false;
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
if (cleanedUp) return;
|
if (cleanedUp) return;
|
||||||
cleanedUp = true;
|
cleanedUp = true;
|
||||||
if (daemon) await daemon.stop("SIGKILL");
|
if (daemon) await daemon.stop("SIGKILL");
|
||||||
if (fakeGitLab) fakeGitLab.close();
|
if (fakeGitLab) fakeGitLab.close();
|
||||||
if (tmpDir && process.env.LVMH_E2E_KEEP !== "1") {
|
if (tmpDir && process.env.LVMH_E2E_KEEP !== "1") {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
} else if (tmpDir) {
|
} else if (tmpDir) {
|
||||||
console.log(`(kept harness artifacts: ${tmpDir})`);
|
console.log(`(kept harness artifacts: ${tmpDir})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
void cleanup().finally(() => process.exit(130));
|
void cleanup().finally(() => process.exit(130));
|
||||||
});
|
});
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
void cleanup().finally(() => process.exit(143));
|
void cleanup().finally(() => process.exit(143));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("=== lvmh e2e integration ===");
|
console.log("=== lvmh e2e integration ===");
|
||||||
const built = ensureWebDist();
|
const built = ensureWebDist();
|
||||||
console.log(`web dist: ${built ? "built now" : "reused"} ${path.relative(REPO_ROOT, WEB_DIST)}`);
|
console.log(
|
||||||
|
`web dist: ${built ? "built now" : "reused"} ${path.relative(REPO_ROOT, WEB_DIST)}`,
|
||||||
|
);
|
||||||
|
|
||||||
fs.mkdirSync(path.join(REPO_ROOT, ".pi", "scratch"), { recursive: true });
|
fs.mkdirSync(path.join(REPO_ROOT, ".pi", "scratch"), { recursive: true });
|
||||||
tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, ".pi", "scratch", "e2e-"));
|
tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, ".pi", "scratch", "e2e-"));
|
||||||
const dbPath = path.join(tmpDir, "lvmh.db");
|
const dbPath = path.join(tmpDir, "lvmh.db");
|
||||||
const logPath = path.join(tmpDir, "daemon.log");
|
const logPath = path.join(tmpDir, "daemon.log");
|
||||||
|
|
||||||
fakeGitLab = await startFakeGitLab();
|
fakeGitLab = await startFakeGitLab();
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const addr = `127.0.0.1:${port}`;
|
const addr = `127.0.0.1:${port}`;
|
||||||
const daemonEnv = {
|
const daemonEnv = {
|
||||||
LVMH_TOKEN: TEST_TOKEN,
|
LVMH_TOKEN: TEST_TOKEN,
|
||||||
LVMH_DB: dbPath,
|
LVMH_DB: dbPath,
|
||||||
GITLAB_BASE_URL: fakeGitLab.url,
|
GITLAB_BASE_URL: fakeGitLab.url,
|
||||||
LVMH_REPO_DIR: path.join(tmpDir, "repos"),
|
LVMH_REPO_DIR: path.join(tmpDir, "repos"),
|
||||||
LVMH_CONTAINER_LVMH_URL: `ws://${addr}/agent/ws`,
|
LVMH_CONTAINER_LVMH_URL: `ws://${addr}/agent/ws`,
|
||||||
LVMH_WORKER_DOCKERFILE: path.join(tmpDir, "absent-worker.Dockerfile"),
|
LVMH_WORKER_DOCKERFILE: path.join(tmpDir, "absent-worker.Dockerfile"),
|
||||||
};
|
};
|
||||||
const boot = () => startDaemon({ addr, dbPath, webdist: WEB_DIST, logPath, env: daemonEnv });
|
const boot = () =>
|
||||||
daemon = await boot();
|
startDaemon({ addr, dbPath, webdist: WEB_DIST, logPath, env: daemonEnv });
|
||||||
|
daemon = await boot();
|
||||||
|
|
||||||
console.log(`daemon: ${daemon.baseUrl} (go run . in ${path.relative(REPO_ROOT, DAEMON_DIR)}, db ${dbPath})`);
|
console.log(
|
||||||
console.log(`fake gitlab: ${fakeGitLab.url}`);
|
`daemon: ${daemon.baseUrl} (go run . in ${path.relative(REPO_ROOT, DAEMON_DIR)}, db ${dbPath})`,
|
||||||
|
);
|
||||||
|
console.log(`fake gitlab: ${fakeGitLab.url}`);
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
r,
|
r,
|
||||||
state: {},
|
state: {},
|
||||||
base: daemon.baseUrl,
|
base: daemon.baseUrl,
|
||||||
token: TEST_TOKEN,
|
token: TEST_TOKEN,
|
||||||
agentUrl: daemon.agentUrl,
|
agentUrl: daemon.agentUrl,
|
||||||
webUrl: daemon.webUrl,
|
webUrl: daemon.webUrl,
|
||||||
gitlab: fakeGitLab,
|
gitlab: fakeGitLab,
|
||||||
restartDaemon: async () => {
|
restartDaemon: async () => {
|
||||||
await daemon.stop("SIGKILL");
|
await daemon.stop("SIGKILL");
|
||||||
daemon = await boot();
|
daemon = await boot();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [name, scenario] of SCENARIOS) {
|
for (const [name, scenario] of SCENARIOS) {
|
||||||
r.group(name);
|
r.group(name);
|
||||||
try {
|
try {
|
||||||
await scenario(ctx);
|
await scenario(ctx);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
r.check(`${name}: scenario crashed`, false, String(err?.stack ?? err));
|
r.check(`${name}: scenario crashed`, false, String(err?.stack ?? err));
|
||||||
const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8").split("\n").slice(-15).join("\n") : "";
|
const log = fs.existsSync(logPath)
|
||||||
if (log) console.log(` daemon.log tail:\n${log.split("\n").map((l) => " " + l).join("\n")}`);
|
? fs.readFileSync(logPath, "utf8").split("\n").slice(-15).join("\n")
|
||||||
}
|
: "";
|
||||||
}
|
if (log)
|
||||||
|
console.log(
|
||||||
|
` daemon.log tail:\n${log
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => " " + l)
|
||||||
|
.join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { ok, fail, xfail, xpass, total } = r.summary();
|
const { ok, fail, xfail, xpass, total } = r.summary();
|
||||||
console.log("\n──────────────────────────────");
|
console.log("\n──────────────────────────────");
|
||||||
console.log(`passed ${ok} / ${total} failed ${fail} xfail ${xfail} (known bugs) xpass ${xpass}`);
|
console.log(
|
||||||
if (fail > 0) {
|
`passed ${ok} / ${total} failed ${fail} xfail ${xfail} (known bugs) xpass ${xpass}`,
|
||||||
console.log("failed checks:");
|
);
|
||||||
for (const e of r.entries.filter((e) => e.status === "fail")) {
|
if (fail > 0) {
|
||||||
console.log(` [${e.scenario}] ${e.name}${e.detail ? " — " + e.detail : ""}`);
|
console.log("failed checks:");
|
||||||
}
|
for (const e of r.entries.filter((e) => e.status === "fail")) {
|
||||||
}
|
console.log(
|
||||||
console.log(`RESULT: ${fail === 0 ? "GREEN" : "RED"}`);
|
` [${e.scenario}] ${e.name}${e.detail ? " — " + e.detail : ""}`,
|
||||||
process.exitCode = fail === 0 ? 0 : 1;
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`RESULT: ${fail === 0 ? "GREEN" : "RED"}`);
|
||||||
|
process.exitCode = fail === 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("harness crashed:", err);
|
console.error("harness crashed:", err);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
})
|
})
|
||||||
.finally(() => cleanup());
|
.finally(() => cleanup());
|
||||||
|
|||||||
+151
-113
@@ -9,130 +9,168 @@
|
|||||||
// exact drift that crashed React #31 in production. This scenario captures a
|
// exact drift that crashed React #31 in production. This scenario captures a
|
||||||
// real session via the real plugin when possible and hard-fails on any frame
|
// real session via the real plugin when possible and hard-fails on any frame
|
||||||
// shape the web UI cannot consume safely.
|
// shape the web UI cannot consume safely.
|
||||||
import { WSSock, agentHello, sid, sessionSnapshot, rest, sleep } from "../lib.mjs";
|
import {
|
||||||
|
WSSock,
|
||||||
|
agentHello,
|
||||||
|
sid,
|
||||||
|
sessionSnapshot,
|
||||||
|
rest,
|
||||||
|
sleep,
|
||||||
|
} from "../lib.mjs";
|
||||||
|
|
||||||
const SESSION_ID = sid("e2e-contract");
|
const SESSION_ID = sid("e2e-contract");
|
||||||
|
|
||||||
// Shape predicates — the web's actual consumption rules (web/src/derive.ts).
|
// Shape predicates — the web's actual consumption rules (web/src/derive.ts).
|
||||||
function frameRenderViolations(frame) {
|
function frameRenderViolations(frame) {
|
||||||
const errs = [];
|
const errs = [];
|
||||||
if (frame.type === "tool_execution_start" || frame.type === "tool_execution_update") {
|
if (
|
||||||
// args: unknown is fine (deriveChat normalizes) — but circular/unserializable
|
frame.type === "tool_execution_start" ||
|
||||||
// payloads would break the daemon's persistence; verify JSON round-trip.
|
frame.type === "tool_execution_update"
|
||||||
if (frame.args !== undefined && frame.args !== null) {
|
) {
|
||||||
try {
|
// args: unknown is fine (deriveChat normalizes) — but circular/unserializable
|
||||||
JSON.parse(JSON.stringify(frame.args));
|
// payloads would break the daemon's persistence; verify JSON round-trip.
|
||||||
} catch {
|
if (frame.args !== undefined && frame.args !== null) {
|
||||||
errs.push(`${frame.type}.args not JSON-safe: ${String(frame.args).slice(0, 60)}`);
|
try {
|
||||||
}
|
JSON.parse(JSON.stringify(frame.args));
|
||||||
}
|
} catch {
|
||||||
}
|
errs.push(
|
||||||
if (frame.type === "message_end") {
|
`${frame.type}.args not JSON-safe: ${String(frame.args).slice(0, 60)}`,
|
||||||
const m = frame.message;
|
);
|
||||||
if (typeof m?.text !== "string") errs.push("message_end.message.text not string");
|
}
|
||||||
for (const c of m?.toolCalls ?? []) {
|
}
|
||||||
if (typeof c?.argsJson !== "string") errs.push(`toolCall ${c?.id} argsJson not string`);
|
}
|
||||||
if (typeof c?.name !== "string") errs.push(`toolCall ${c?.id} name not string`);
|
if (frame.type === "message_end") {
|
||||||
}
|
const m = frame.message;
|
||||||
}
|
if (typeof m?.text !== "string")
|
||||||
return errs;
|
errs.push("message_end.message.text not string");
|
||||||
|
for (const c of m?.toolCalls ?? []) {
|
||||||
|
if (typeof c?.argsJson !== "string")
|
||||||
|
errs.push(`toolCall ${c?.id} argsJson not string`);
|
||||||
|
if (typeof c?.name !== "string")
|
||||||
|
errs.push(`toolCall ${c?.id} name not string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(ctx) {
|
export async function run(ctx) {
|
||||||
const { r, base, token, agentUrl } = ctx;
|
const { r, base, token, agentUrl } = ctx;
|
||||||
const session = sessionSnapshot(SESSION_ID);
|
const session = sessionSnapshot(SESSION_ID);
|
||||||
const agent = new WSSock(agentUrl, { headers: { Authorization: `Bearer ${token}` } });
|
const agent = new WSSock(agentUrl, {
|
||||||
if (!(await agent.opened())) {
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
r.check("contract agent connects", false, "agent WS failed to open");
|
});
|
||||||
return;
|
if (!(await agent.opened())) {
|
||||||
}
|
r.check("contract agent connects", false, "agent WS failed to open");
|
||||||
agentHello(agent, session);
|
return;
|
||||||
await agent.waitForFrame((f) => f.type === "welcome");
|
}
|
||||||
|
agentHello(agent, session);
|
||||||
|
await agent.waitForFrame((f) => f.type === "welcome");
|
||||||
|
|
||||||
// Frames shaped exactly as plugin/lvmh-agent.ts emits them for a real
|
// Frames shaped exactly as plugin/lvmh-agent.ts emits them for a real
|
||||||
// pi turn: object tool args (read tool), string message text, assistant
|
// pi turn: object tool args (read tool), string message text, assistant
|
||||||
// toolCalls with stringified argsJson. Mirrors the crash trace: read tool
|
// toolCalls with stringified argsJson. Mirrors the crash trace: read tool
|
||||||
// with args {path} — the frame that produced React #31 before the fix.
|
// with args {path} — the frame that produced React #31 before the fix.
|
||||||
const frames = [
|
const frames = [
|
||||||
{ type: "agent_start", seq: 1 },
|
{ type: "agent_start", seq: 1 },
|
||||||
{
|
{
|
||||||
type: "tool_execution_start",
|
type: "tool_execution_start",
|
||||||
seq: 2,
|
seq: 2,
|
||||||
toolCallId: "call-read-1",
|
toolCallId: "call-read-1",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
args: { path: "src/main.ts", offset: 1 },
|
args: { path: "src/main.ts", offset: 1 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool_execution_update",
|
type: "tool_execution_update",
|
||||||
seq: 3,
|
seq: 3,
|
||||||
toolCallId: "call-read-1",
|
toolCallId: "call-read-1",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
partial: "…file body…",
|
partial: "…file body…",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool_execution_end",
|
type: "tool_execution_end",
|
||||||
seq: 4,
|
seq: 4,
|
||||||
toolCallId: "call-read-1",
|
toolCallId: "call-read-1",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
isError: false,
|
isError: false,
|
||||||
resultPreview: "1: import x",
|
resultPreview: "1: import x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "message_update",
|
type: "message_update",
|
||||||
seq: 5,
|
seq: 5,
|
||||||
delta: "Reading ",
|
delta: "Reading ",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "message_update",
|
type: "message_update",
|
||||||
seq: 6,
|
seq: 6,
|
||||||
delta: "src/main.ts",
|
delta: "src/main.ts",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "message_end",
|
type: "message_end",
|
||||||
seq: 7,
|
seq: 7,
|
||||||
message: {
|
message: {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
id: "msg-1",
|
id: "msg-1",
|
||||||
text: "Reading src/main.ts",
|
text: "Reading src/main.ts",
|
||||||
thinking: null,
|
thinking: null,
|
||||||
toolCalls: [{ id: "call-read-1", name: "read", argsJson: '{"path":"src/main.ts","offset":1}' }],
|
toolCalls: [
|
||||||
toolCallId: null,
|
{
|
||||||
},
|
id: "call-read-1",
|
||||||
},
|
name: "read",
|
||||||
{ type: "agent_settled", seq: 8 },
|
argsJson: '{"path":"src/main.ts","offset":1}',
|
||||||
];
|
},
|
||||||
for (const f of frames) {
|
],
|
||||||
agent.send({ v: 1, sessionId: session.id, ts: Date.now(), ...f, seq: f.seq });
|
toolCallId: null,
|
||||||
}
|
},
|
||||||
// Wait for persistence to catch up, then read back everything the web would.
|
},
|
||||||
const persisted = await (async () => {
|
{ type: "agent_settled", seq: 8 },
|
||||||
for (let i = 0; i < 40; i++) {
|
];
|
||||||
const res = await rest(base, token, `/api/sessions/${session.id}/events?after=0&limit=100`);
|
for (const f of frames) {
|
||||||
if (res.json?.length >= 6) return res.json;
|
agent.send({
|
||||||
await sleep(25);
|
v: 1,
|
||||||
}
|
sessionId: session.id,
|
||||||
return [];
|
ts: Date.now(),
|
||||||
})();
|
...f,
|
||||||
|
seq: f.seq,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Wait for persistence to catch up, then read back everything the web would.
|
||||||
|
const persisted = await (async () => {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const res = await rest(
|
||||||
|
base,
|
||||||
|
token,
|
||||||
|
`/api/sessions/${session.id}/events?after=0&limit=100`,
|
||||||
|
);
|
||||||
|
if (res.json?.length >= 6) return res.json;
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
r.check("contract: all 6 persisted frames round-tripped", persisted.length === 6, `got ${persisted.length}`);
|
r.check(
|
||||||
r.check(
|
"contract: all 6 persisted frames round-tripped",
|
||||||
"contract: message_update not persisted",
|
persisted.length === 6,
|
||||||
persisted.every((f) => f.type !== "message_update"),
|
`got ${persisted.length}`,
|
||||||
);
|
);
|
||||||
|
r.check(
|
||||||
|
"contract: message_update not persisted",
|
||||||
|
persisted.every((f) => f.type !== "message_update"),
|
||||||
|
);
|
||||||
|
|
||||||
const violations = persisted.flatMap(frameRenderViolations);
|
const violations = persisted.flatMap(frameRenderViolations);
|
||||||
r.check(
|
r.check(
|
||||||
"contract: every persisted frame is render-safe for the web",
|
"contract: every persisted frame is render-safe for the web",
|
||||||
violations.length === 0,
|
violations.length === 0,
|
||||||
violations.join("; ").slice(0, 200) || "all shapes consumable",
|
violations.join("; ").slice(0, 200) || "all shapes consumable",
|
||||||
);
|
);
|
||||||
|
|
||||||
const objectArgs = persisted.find((f) => f.type === "tool_execution_start");
|
const objectArgs = persisted.find((f) => f.type === "tool_execution_start");
|
||||||
r.check(
|
r.check(
|
||||||
"contract: object tool args survive persistence verbatim",
|
"contract: object tool args survive persistence verbatim",
|
||||||
typeof objectArgs?.args === "object" && objectArgs.args?.path === "src/main.ts",
|
typeof objectArgs?.args === "object" &&
|
||||||
`args=${JSON.stringify(objectArgs?.args)}`,
|
objectArgs.args?.path === "src/main.ts",
|
||||||
);
|
`args=${JSON.stringify(objectArgs?.args)}`,
|
||||||
|
);
|
||||||
|
|
||||||
agent.close();
|
agent.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame {
|
|||||||
// render as-is (React #31 crash) — deriveChat must normalize to text.
|
// render as-is (React #31 crash) — deriveChat must normalize to text.
|
||||||
describe("object tool args from the real plugin", () => {
|
describe("object tool args from the real plugin", () => {
|
||||||
it("argsText normalizes every shape the plugin emits", () => {
|
it("argsText normalizes every shape the plugin emits", () => {
|
||||||
expect(argsText({ path: "src/main.ts" })).toBe('{\n "path": "src/main.ts"\n}');
|
expect(argsText({ path: "src/main.ts" })).toBe(
|
||||||
|
'{\n "path": "src/main.ts"\n}',
|
||||||
|
);
|
||||||
expect(argsText('"plain"')).toBe('"plain"');
|
expect(argsText('"plain"')).toBe('"plain"');
|
||||||
expect(argsText(42)).toBe("42");
|
expect(argsText(42)).toBe("42");
|
||||||
expect(argsText(null)).toBe("");
|
expect(argsText(null)).toBe("");
|
||||||
|
|||||||
+271
-243
@@ -2,137 +2,143 @@ import type { EventFrame } from "./protocol";
|
|||||||
|
|
||||||
// ---------- event list merge ----------
|
// ---------- event list merge ----------
|
||||||
|
|
||||||
export function mergeEvents(existing: EventFrame[], incoming: EventFrame[]): EventFrame[] {
|
export function mergeEvents(
|
||||||
const bySeq = new Map<number, EventFrame>();
|
existing: EventFrame[],
|
||||||
for (const e of existing) bySeq.set(e.seq, e);
|
incoming: EventFrame[],
|
||||||
for (const e of incoming) bySeq.set(e.seq, e);
|
): EventFrame[] {
|
||||||
return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq);
|
const bySeq = new Map<number, EventFrame>();
|
||||||
|
for (const e of existing) bySeq.set(e.seq, e);
|
||||||
|
for (const e of incoming) bySeq.set(e.seq, e);
|
||||||
|
return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Highest persisted seq — deltas (`message_update`) are not persisted, so they
|
/** Highest persisted seq — deltas (`message_update`) are not persisted, so they
|
||||||
* must not advance the refetch cursor. */
|
* must not advance the refetch cursor. */
|
||||||
export function lastPersistedSeq(events: EventFrame[]): number {
|
export function lastPersistedSeq(events: EventFrame[]): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const e of events) if (e.type !== "message_update" && e.seq > max) max = e.seq;
|
for (const e of events)
|
||||||
return max;
|
if (e.type !== "message_update" && e.seq > max) max = e.seq;
|
||||||
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- chat view derivation ----------
|
// ---------- chat view derivation ----------
|
||||||
|
|
||||||
export interface ToolState {
|
export interface ToolState {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
args: string;
|
args: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
preview: string;
|
preview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
key: string;
|
key: string;
|
||||||
role: "user" | "assistant" | "system" | "toolResult";
|
role: "user" | "assistant" | "system" | "toolResult";
|
||||||
text: string;
|
text: string;
|
||||||
thinking: string | null;
|
thinking: string | null;
|
||||||
toolCalls: { id: string; name: string; argsJson: string }[];
|
toolCalls: { id: string; name: string; argsJson: string }[];
|
||||||
toolCallId: string | null;
|
toolCallId: string | null;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatDerivation {
|
export interface ChatDerivation {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
tools: Map<string, ToolState>;
|
tools: Map<string, ToolState>;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plugin mirrors pi extension events verbatim: tool args arrive as a raw
|
// The plugin mirrors pi extension events verbatim: tool args arrive as a raw
|
||||||
// JSON object (or string in older frames). Normalizing at this boundary keeps
|
// JSON object (or string in older frames). Normalizing at this boundary keeps
|
||||||
// the render layer string-only; regression: object args crashed React (#31).
|
// the render layer string-only; regression: object args crashed React (#31).
|
||||||
export function argsText(args: unknown): string {
|
export function argsText(args: unknown): string {
|
||||||
if (typeof args === "string") return args;
|
if (typeof args === "string") return args;
|
||||||
if (args === null || args === undefined) return "";
|
if (args === null || args === undefined) return "";
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(args, null, 2) ?? "";
|
return JSON.stringify(args, null, 2) ?? "";
|
||||||
} catch {
|
} catch {
|
||||||
return String(args);
|
return String(args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveChat(events: EventFrame[]): ChatDerivation {
|
export function deriveChat(events: EventFrame[]): ChatDerivation {
|
||||||
const messages: ChatMessage[] = [];
|
const messages: ChatMessage[] = [];
|
||||||
const tools = new Map<string, ToolState>();
|
const tools = new Map<string, ToolState>();
|
||||||
let stream: { id: string; text: string } | null = null;
|
let stream: { id: string; text: string } | null = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case "tool_execution_start": {
|
case "tool_execution_start": {
|
||||||
if (e.toolCallId !== undefined) {
|
if (e.toolCallId !== undefined) {
|
||||||
tools.set(e.toolCallId, {
|
tools.set(e.toolCallId, {
|
||||||
id: e.toolCallId,
|
id: e.toolCallId,
|
||||||
name: e.toolName ?? "tool",
|
name: e.toolName ?? "tool",
|
||||||
args: argsText(e.args),
|
args: argsText(e.args),
|
||||||
running: true,
|
running: true,
|
||||||
isError: false,
|
isError: false,
|
||||||
preview: "",
|
preview: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "tool_execution_end": {
|
case "tool_execution_end": {
|
||||||
const t = e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined;
|
const t =
|
||||||
if (t !== undefined) {
|
e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined;
|
||||||
t.running = false;
|
if (t !== undefined) {
|
||||||
t.isError = e.isError ?? false;
|
t.running = false;
|
||||||
t.preview = e.resultPreview ?? "";
|
t.isError = e.isError ?? false;
|
||||||
}
|
t.preview = e.resultPreview ?? "";
|
||||||
break;
|
}
|
||||||
}
|
break;
|
||||||
case "agent_start":
|
}
|
||||||
busy = true;
|
case "agent_start":
|
||||||
break;
|
busy = true;
|
||||||
case "agent_settled":
|
break;
|
||||||
busy = false;
|
case "agent_settled":
|
||||||
break;
|
busy = false;
|
||||||
case "message_start":
|
break;
|
||||||
if (e.message?.role === "assistant") stream = { id: e.message.id, text: "" };
|
case "message_start":
|
||||||
break;
|
if (e.message?.role === "assistant")
|
||||||
case "message_update":
|
stream = { id: e.message.id, text: "" };
|
||||||
if (stream !== null) stream.text += e.delta ?? "";
|
break;
|
||||||
break;
|
case "message_update":
|
||||||
case "message_end": {
|
if (stream !== null) stream.text += e.delta ?? "";
|
||||||
const m = e.message;
|
break;
|
||||||
if (m !== undefined) {
|
case "message_end": {
|
||||||
if (stream !== null && stream.id === m.id) stream = null;
|
const m = e.message;
|
||||||
messages.push({
|
if (m !== undefined) {
|
||||||
key: `msg-${e.seq}`,
|
if (stream !== null && stream.id === m.id) stream = null;
|
||||||
role: m.role,
|
messages.push({
|
||||||
text: m.text,
|
key: `msg-${e.seq}`,
|
||||||
thinking: m.thinking,
|
role: m.role,
|
||||||
toolCalls: m.toolCalls ?? [],
|
text: m.text,
|
||||||
toolCallId: m.toolCallId,
|
thinking: m.thinking,
|
||||||
streaming: false,
|
toolCalls: m.toolCalls ?? [],
|
||||||
});
|
toolCallId: m.toolCallId,
|
||||||
}
|
streaming: false,
|
||||||
break;
|
});
|
||||||
}
|
}
|
||||||
default:
|
break;
|
||||||
break;
|
}
|
||||||
}
|
default:
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (stream !== null) {
|
if (stream !== null) {
|
||||||
messages.push({
|
messages.push({
|
||||||
key: `stream-${stream.id}`,
|
key: `stream-${stream.id}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
text: stream.text,
|
text: stream.text,
|
||||||
thinking: null,
|
thinking: null,
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
toolCallId: null,
|
toolCallId: null,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { messages, tools, busy: busy || stream !== null };
|
return { messages, tools, busy: busy || stream !== null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ----------
|
// ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ----------
|
||||||
@@ -140,168 +146,190 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
|
|||||||
export type TodoStatus = "pending" | "in-progress" | "completed";
|
export type TodoStatus = "pending" | "in-progress" | "completed";
|
||||||
|
|
||||||
export interface TodoItem {
|
export interface TodoItem {
|
||||||
content: string;
|
content: string;
|
||||||
status: TodoStatus;
|
status: TodoStatus;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubagentRun {
|
export interface SubagentRun {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskDerivation {
|
export interface TaskDerivation {
|
||||||
todos: TodoItem[];
|
todos: TodoItem[];
|
||||||
subagents: SubagentRun[];
|
subagents: SubagentRun[];
|
||||||
workingTools: ToolState[];
|
workingTools: ToolState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TODO_TOOL = "todo";
|
const TODO_TOOL = "todo";
|
||||||
const SUBAGENT_TOOL = "subagent";
|
const SUBAGENT_TOOL = "subagent";
|
||||||
|
|
||||||
function parseJson(raw: unknown): unknown {
|
function parseJson(raw: unknown): unknown {
|
||||||
if (typeof raw === "object" && raw !== null) return raw; // already-decoded args
|
if (typeof raw === "object" && raw !== null) return raw; // already-decoded args
|
||||||
if (typeof raw !== "string" || raw.length === 0) return undefined;
|
if (typeof raw !== "string" || raw.length === 0) return undefined;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as unknown;
|
return JSON.parse(raw) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(raw: unknown): TodoStatus {
|
function normalizeStatus(raw: unknown): TodoStatus {
|
||||||
if (typeof raw !== "string") return "pending";
|
if (typeof raw !== "string") return "pending";
|
||||||
switch (raw.toLowerCase()) {
|
switch (raw.toLowerCase()) {
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
case "in-progress":
|
case "in-progress":
|
||||||
case "inprogress":
|
case "inprogress":
|
||||||
case "in progress":
|
case "in progress":
|
||||||
case "doing":
|
case "doing":
|
||||||
case "started":
|
case "started":
|
||||||
return "in-progress";
|
return "in-progress";
|
||||||
case "completed":
|
case "completed":
|
||||||
case "complete":
|
case "complete":
|
||||||
case "done":
|
case "done":
|
||||||
return "completed";
|
return "completed";
|
||||||
default:
|
default:
|
||||||
return "pending";
|
return "pending";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSnapshot(raw: unknown): { content: string; status: TodoStatus }[] | null {
|
function extractSnapshot(
|
||||||
let arr: unknown = raw;
|
raw: unknown,
|
||||||
if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") {
|
): { content: string; status: TodoStatus }[] | null {
|
||||||
const o = raw as Record<string, unknown>;
|
let arr: unknown = raw;
|
||||||
const nested = o.todos ?? o.items ?? o.tasks ?? o.list;
|
if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") {
|
||||||
if (Array.isArray(nested)) arr = nested;
|
const o = raw as Record<string, unknown>;
|
||||||
}
|
const nested = o.todos ?? o.items ?? o.tasks ?? o.list;
|
||||||
if (Array.isArray(arr) === false) return null;
|
if (Array.isArray(nested)) arr = nested;
|
||||||
const items: { content: string; status: TodoStatus }[] = [];
|
}
|
||||||
for (const entry of arr) {
|
if (Array.isArray(arr) === false) return null;
|
||||||
if (typeof entry === "string") {
|
const items: { content: string; status: TodoStatus }[] = [];
|
||||||
items.push({ content: entry, status: "pending" });
|
for (const entry of arr) {
|
||||||
continue;
|
if (typeof entry === "string") {
|
||||||
}
|
items.push({ content: entry, status: "pending" });
|
||||||
if (entry !== null && typeof entry === "object") {
|
continue;
|
||||||
const o = entry as Record<string, unknown>;
|
}
|
||||||
const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary;
|
if (entry !== null && typeof entry === "object") {
|
||||||
if (typeof content === "string" && content.length > 0) {
|
const o = entry as Record<string, unknown>;
|
||||||
items.push({ content, status: normalizeStatus(o.status) });
|
const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary;
|
||||||
}
|
if (typeof content === "string" && content.length > 0) {
|
||||||
}
|
items.push({ content, status: normalizeStatus(o.status) });
|
||||||
}
|
}
|
||||||
return items.length > 0 ? items : null;
|
}
|
||||||
|
}
|
||||||
|
return items.length > 0 ? items : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveTasks(events: EventFrame[]): TaskDerivation {
|
export function deriveTasks(events: EventFrame[]): TaskDerivation {
|
||||||
const toolNames = new Map<string, string>(); // toolCallId -> toolName
|
const toolNames = new Map<string, string>(); // toolCallId -> toolName
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
|
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
|
||||||
toolNames.set(e.toolCallId, e.toolName ?? "");
|
toolNames.set(e.toolCallId, e.toolName ?? "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message
|
// todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message
|
||||||
const snapshots: { seq: number; items: { content: string; status: TodoStatus }[] }[] = [];
|
const snapshots: {
|
||||||
const subagents: SubagentRun[] = [];
|
seq: number;
|
||||||
const runningTools: ToolState[] = [];
|
items: { content: string; status: TodoStatus }[];
|
||||||
|
}[] = [];
|
||||||
|
const subagents: SubagentRun[] = [];
|
||||||
|
const runningTools: ToolState[] = [];
|
||||||
|
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
|
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
|
||||||
const name = e.toolName ?? "";
|
const name = e.toolName ?? "";
|
||||||
if (name === TODO_TOOL) {
|
if (name === TODO_TOOL) {
|
||||||
const snap = extractSnapshot(parseJson(e.args));
|
const snap = extractSnapshot(parseJson(e.args));
|
||||||
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
|
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
|
||||||
} else if (name === SUBAGENT_TOOL) {
|
} else if (name === SUBAGENT_TOOL) {
|
||||||
const parsed = parseJson(e.args);
|
const parsed = parseJson(e.args);
|
||||||
const o = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
const o =
|
||||||
const nameField = o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role;
|
parsed !== null && typeof parsed === "object"
|
||||||
subagents.push({
|
? (parsed as Record<string, unknown>)
|
||||||
key: e.toolCallId,
|
: {};
|
||||||
name: typeof nameField === "string" && nameField.length > 0 ? nameField : "subagent",
|
const nameField =
|
||||||
running: true,
|
o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role;
|
||||||
isError: false,
|
subagents.push({
|
||||||
});
|
key: e.toolCallId,
|
||||||
} else {
|
name:
|
||||||
runningTools.push({
|
typeof nameField === "string" && nameField.length > 0
|
||||||
id: e.toolCallId,
|
? nameField
|
||||||
name,
|
: "subagent",
|
||||||
args: argsText(e.args),
|
running: true,
|
||||||
running: true,
|
isError: false,
|
||||||
isError: false,
|
});
|
||||||
preview: "",
|
} else {
|
||||||
});
|
runningTools.push({
|
||||||
}
|
id: e.toolCallId,
|
||||||
} else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) {
|
name,
|
||||||
const name = toolNames.get(e.toolCallId) ?? "";
|
args: argsText(e.args),
|
||||||
if (name === SUBAGENT_TOOL) {
|
running: true,
|
||||||
const run = subagents.find((s) => s.key === e.toolCallId);
|
isError: false,
|
||||||
if (run !== undefined) {
|
preview: "",
|
||||||
run.running = false;
|
});
|
||||||
run.isError = e.isError ?? false;
|
}
|
||||||
}
|
} else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) {
|
||||||
} else if (name !== TODO_TOOL) {
|
const name = toolNames.get(e.toolCallId) ?? "";
|
||||||
const t = runningTools.find((w) => w.id === e.toolCallId);
|
if (name === SUBAGENT_TOOL) {
|
||||||
if (t !== undefined) {
|
const run = subagents.find((s) => s.key === e.toolCallId);
|
||||||
t.running = false;
|
if (run !== undefined) {
|
||||||
t.isError = e.isError ?? false;
|
run.running = false;
|
||||||
t.preview = e.resultPreview ?? "";
|
run.isError = e.isError ?? false;
|
||||||
}
|
}
|
||||||
}
|
} else if (name !== TODO_TOOL) {
|
||||||
if (name === TODO_TOOL) {
|
const t = runningTools.find((w) => w.id === e.toolCallId);
|
||||||
const snap = extractSnapshot(parseJson(e.resultPreview));
|
if (t !== undefined) {
|
||||||
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
|
t.running = false;
|
||||||
}
|
t.isError = e.isError ?? false;
|
||||||
} else if (e.type === "message_end" && e.message?.role === "toolResult" && e.message.toolCallId !== null) {
|
t.preview = e.resultPreview ?? "";
|
||||||
const name = toolNames.get(e.message.toolCallId) ?? "";
|
}
|
||||||
if (name === TODO_TOOL) {
|
}
|
||||||
const snap = extractSnapshot(parseJson(e.message.text));
|
if (name === TODO_TOOL) {
|
||||||
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
|
const snap = extractSnapshot(parseJson(e.resultPreview));
|
||||||
}
|
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
|
||||||
}
|
}
|
||||||
}
|
} else if (
|
||||||
|
e.type === "message_end" &&
|
||||||
|
e.message?.role === "toolResult" &&
|
||||||
|
e.message.toolCallId !== null
|
||||||
|
) {
|
||||||
|
const name = toolNames.get(e.message.toolCallId) ?? "";
|
||||||
|
if (name === TODO_TOOL) {
|
||||||
|
const snap = extractSnapshot(parseJson(e.message.text));
|
||||||
|
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// todos: latest snapshot wins; earlier items missing from it are deleted
|
// todos: latest snapshot wins; earlier items missing from it are deleted
|
||||||
const todos: TodoItem[] = [];
|
const todos: TodoItem[] = [];
|
||||||
if (snapshots.length > 0) {
|
if (snapshots.length > 0) {
|
||||||
snapshots.sort((a, b) => a.seq - b.seq);
|
snapshots.sort((a, b) => a.seq - b.seq);
|
||||||
const latest = snapshots[snapshots.length - 1]?.items ?? [];
|
const latest = snapshots[snapshots.length - 1]?.items ?? [];
|
||||||
const seen = new Map<string, TodoStatus>();
|
const seen = new Map<string, TodoStatus>();
|
||||||
for (const snap of snapshots) {
|
for (const snap of snapshots) {
|
||||||
for (const item of snap.items) if (!seen.has(item.content)) seen.set(item.content, item.status);
|
for (const item of snap.items)
|
||||||
}
|
if (!seen.has(item.content)) seen.set(item.content, item.status);
|
||||||
for (const item of latest) {
|
}
|
||||||
todos.push({ content: item.content, status: item.status, deleted: false });
|
for (const item of latest) {
|
||||||
seen.delete(item.content);
|
todos.push({
|
||||||
}
|
content: item.content,
|
||||||
for (const [content] of seen) todos.push({ content, status: "pending", deleted: true });
|
status: item.status,
|
||||||
}
|
deleted: false,
|
||||||
|
});
|
||||||
|
seen.delete(item.content);
|
||||||
|
}
|
||||||
|
for (const [content] of seen)
|
||||||
|
todos.push({ content, status: "pending", deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
todos,
|
todos,
|
||||||
subagents,
|
subagents,
|
||||||
workingTools: runningTools.filter((t) => t.running),
|
workingTools: runningTools.filter((t) => t.running),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-89
@@ -4,155 +4,159 @@ export const PROTOCOL_VERSION: 1 = 1;
|
|||||||
|
|
||||||
/** Event types emitted by the plugin, carried in the envelope `type` field. */
|
/** Event types emitted by the plugin, carried in the envelope `type` field. */
|
||||||
export const EventType = {
|
export const EventType = {
|
||||||
Hello: "hello",
|
Hello: "hello",
|
||||||
MessageStart: "message_start",
|
MessageStart: "message_start",
|
||||||
MessageUpdate: "message_update",
|
MessageUpdate: "message_update",
|
||||||
MessageEnd: "message_end",
|
MessageEnd: "message_end",
|
||||||
ToolExecutionStart: "tool_execution_start",
|
ToolExecutionStart: "tool_execution_start",
|
||||||
ToolExecutionUpdate: "tool_execution_update",
|
ToolExecutionUpdate: "tool_execution_update",
|
||||||
ToolExecutionEnd: "tool_execution_end",
|
ToolExecutionEnd: "tool_execution_end",
|
||||||
AgentStart: "agent_start",
|
AgentStart: "agent_start",
|
||||||
AgentEnd: "agent_end",
|
AgentEnd: "agent_end",
|
||||||
AgentSettled: "agent_settled",
|
AgentSettled: "agent_settled",
|
||||||
SessionInfo: "session_info",
|
SessionInfo: "session_info",
|
||||||
Bye: "bye",
|
Bye: "bye",
|
||||||
} as const;
|
} as const;
|
||||||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||||
|
|
||||||
/** REST routes (base `/api`, bearer auth). */
|
/** REST routes (base `/api`, bearer auth). */
|
||||||
export const Route = {
|
export const Route = {
|
||||||
Sessions: "/api/sessions",
|
Sessions: "/api/sessions",
|
||||||
SessionEvents: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/events`,
|
SessionEvents: (id: string): string =>
|
||||||
SessionPrompt: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/prompt`,
|
`/api/sessions/${encodeURIComponent(id)}/events`,
|
||||||
SessionAbort: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/abort`,
|
SessionPrompt: (id: string): string =>
|
||||||
SessionContainer: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/container`,
|
`/api/sessions/${encodeURIComponent(id)}/prompt`,
|
||||||
Spawn: "/api/spawn",
|
SessionAbort: (id: string): string =>
|
||||||
SpawnStatus: "/api/spawn/status",
|
`/api/sessions/${encodeURIComponent(id)}/abort`,
|
||||||
GitlabStatus: "/api/gitlab/status",
|
SessionContainer: (id: string): string =>
|
||||||
GitlabConnect: "/api/gitlab/connect",
|
`/api/sessions/${encodeURIComponent(id)}/container`,
|
||||||
GitlabRepos: "/api/gitlab/repos",
|
Spawn: "/api/spawn",
|
||||||
|
SpawnStatus: "/api/spawn/status",
|
||||||
|
GitlabStatus: "/api/gitlab/status",
|
||||||
|
GitlabConnect: "/api/gitlab/connect",
|
||||||
|
GitlabRepos: "/api/gitlab/repos",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Session snapshot, carried by `hello`/`session_info` and REST session list. */
|
/** Session snapshot, carried by `hello`/`session_info` and REST session list. */
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string;
|
model: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
/** true if spawned-by-daemon container session */
|
/** true if spawned-by-daemon container session */
|
||||||
agent: boolean;
|
agent: boolean;
|
||||||
repo: string | null;
|
repo: string | null;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Session list row (REST `GET /api/sessions` + WS `session_list`). */
|
/** Session list row (REST `GET /api/sessions` + WS `session_list`). */
|
||||||
export interface SessionListItem extends SessionInfo {
|
export interface SessionListItem extends SessionInfo {
|
||||||
online: boolean;
|
online: boolean;
|
||||||
lastEventAt: number | null;
|
lastEventAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageRole = "user" | "assistant" | "toolResult" | "system";
|
export type MessageRole = "user" | "assistant" | "toolResult" | "system";
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
argsJson: string;
|
argsJson: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role: MessageRole;
|
role: MessageRole;
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
thinking: string | null;
|
thinking: string | null;
|
||||||
toolCalls: ToolCall[];
|
toolCalls: ToolCall[];
|
||||||
/** toolResult messages: which call this answers */
|
/** toolResult messages: which call this answers */
|
||||||
toolCallId: string | null;
|
toolCallId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persisted event envelope + flattened payload. */
|
/** Persisted event envelope + flattened payload. */
|
||||||
export interface EventFrame {
|
export interface EventFrame {
|
||||||
v: 1;
|
v: 1;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** monotonic per-session, plugin-assigned, starts at 1 */
|
/** monotonic per-session, plugin-assigned, starts at 1 */
|
||||||
seq: number;
|
seq: number;
|
||||||
/** unix ms */
|
/** unix ms */
|
||||||
ts: number;
|
ts: number;
|
||||||
type: EventType | string;
|
type: EventType | string;
|
||||||
// ---- payload fields (union, present depending on `type`) ----
|
// ---- payload fields (union, present depending on `type`) ----
|
||||||
session?: SessionInfo;
|
session?: SessionInfo;
|
||||||
message?: Message;
|
message?: Message;
|
||||||
delta?: string;
|
delta?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
args?: unknown;
|
args?: unknown;
|
||||||
partial?: string;
|
partial?: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number };
|
usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number };
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Query params + response shapes for REST routes. */
|
/** Query params + response shapes for REST routes. */
|
||||||
export interface PromptBody {
|
export interface PromptBody {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptResponse {
|
export interface PromptResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AbortResponse {
|
export interface AbortResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerResponse {
|
export interface ContainerResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpawnBody {
|
export interface SpawnBody {
|
||||||
repo: string;
|
repo: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpawnResponse {
|
export interface SpawnResponse {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
containerId: string;
|
containerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpawnJob {
|
export interface SpawnJob {
|
||||||
repo: string;
|
repo: string;
|
||||||
state: string;
|
state: string;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitlabStatus {
|
export interface GitlabStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitlabConnectResponse {
|
export interface GitlabConnectResponse {
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Repo {
|
export interface Repo {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
lastActivityAt: string;
|
lastActivityAt: string;
|
||||||
webUrl: string;
|
webUrl: string;
|
||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ---- WS transport 3 (browser → daemon) ---- */
|
/** ---- WS transport 3 (browser → daemon) ---- */
|
||||||
|
|
||||||
export type ServerFrame =
|
export type ServerFrame =
|
||||||
| { type: "session_list"; sessions: SessionListItem[] }
|
| { type: "session_list"; sessions: SessionListItem[] }
|
||||||
| { type: "events"; sessionId: string; after: number; events: EventFrame[] }
|
| { type: "events"; sessionId: string; after: number; events: EventFrame[] }
|
||||||
| { type: "spawn_status"; jobs: SpawnJob[] };
|
| { type: "spawn_status"; jobs: SpawnJob[] };
|
||||||
|
|
||||||
export type ClientFrame =
|
export type ClientFrame =
|
||||||
| { type: "subscribe"; sessionId: string }
|
| { type: "subscribe"; sessionId: string }
|
||||||
| { type: "unsubscribe"; sessionId: string };
|
| { type: "unsubscribe"; sessionId: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user