feat: add multi-session desktop workspace
This commit is contained in:
@@ -1,16 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
|
||||
import {
|
||||
createAgentRegistry,
|
||||
sessionDirectoryPath,
|
||||
} from "../src/bridge/agent-registry.js";
|
||||
|
||||
test("recovers an exited agent with its last Pi session reference", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-recovery-"));
|
||||
const home = join(root, "home");
|
||||
const sessionRoot = join(root, "sessions");
|
||||
await mkdir(home);
|
||||
const sessionFile = join(sessionRoot, "pi-session.jsonl");
|
||||
const sessionDir = sessionDirectoryPath(sessionRoot, home);
|
||||
await mkdir(sessionDir, { recursive: true });
|
||||
const sessionFile = join(sessionDir, "pi-session.jsonl");
|
||||
await writeFile(
|
||||
sessionFile,
|
||||
`${JSON.stringify({ type: "session", version: 3, id: "recovery", cwd: home })}\n`,
|
||||
);
|
||||
const calls = [];
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: home,
|
||||
|
||||
+43
-15
@@ -134,6 +134,10 @@ test("drains a forget still resolving its canonical path during shutdown", async
|
||||
await stop();
|
||||
};
|
||||
|
||||
const selectedFeature = registry
|
||||
.listAgents()
|
||||
.find((agent) => agent.worktreePath === worktrees.feature);
|
||||
await registry.closeSessionRuntime(selectedFeature.runtimeId);
|
||||
const forgetting = registry.forgetDirectory(featureAlias);
|
||||
const stopping = registry.stop();
|
||||
await Promise.all([forgetting, stopping]);
|
||||
@@ -170,6 +174,11 @@ test("forgets a directory without deleting its resumable session history", async
|
||||
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
await registry.closeSessionRuntime(feature.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
@@ -227,10 +236,14 @@ test("coordinates forgetting with directory creation and active commands", async
|
||||
|
||||
const selecting = registry.selectWorktree(worktrees.feature);
|
||||
await featureStarted;
|
||||
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
|
||||
releaseFeatureCreation();
|
||||
await selecting;
|
||||
await forgettingDuringCreation;
|
||||
const selectedDuringCreation = await selecting;
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
await registry.closeSessionRuntime(selectedDuringCreation.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
assert.equal(
|
||||
(await registry.listDirectories()).some(
|
||||
@@ -252,15 +265,18 @@ test("coordinates forgetting with directory creation and active commands", async
|
||||
await stopGate;
|
||||
await stop();
|
||||
};
|
||||
const firstForget = registry.forgetDirectory(worktrees.feature);
|
||||
const secondForget = registry.forgetDirectory(worktrees.feature);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await assert.rejects(
|
||||
registry.selectWorktree(worktrees.feature),
|
||||
/being forgotten/,
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
const closing = registry.closeSessionRuntime(
|
||||
registry.listAgents().find((agent) => agent.worktreePath === worktrees.feature)
|
||||
.runtimeId,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
releaseStop();
|
||||
await Promise.all([firstForget, secondForget]);
|
||||
await closing;
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(registry.listAgents().length, 1);
|
||||
|
||||
@@ -276,15 +292,15 @@ test("coordinates forgetting with directory creation and active commands", async
|
||||
const prompt = registry.route(feature.id, "prompt", {
|
||||
message: "Keep working",
|
||||
});
|
||||
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
assert.equal(adapter.stopped, false);
|
||||
releasePrompt({ type: "response", success: true });
|
||||
await prompt;
|
||||
await assert.rejects(forgettingDuringPrompt, /while Pi is working/);
|
||||
assert.equal(adapter.stopped, false);
|
||||
|
||||
options.onEvent({ type: "agent_state", data: { state: "idle" } });
|
||||
await registry.closeSessionRuntime(feature.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
assert.equal(adapter.stopped, true);
|
||||
|
||||
@@ -307,14 +323,19 @@ test("coordinates forgetting with directory creation and active commands", async
|
||||
});
|
||||
rejectPrompt(new Error("send failed"));
|
||||
await failedPromptAssertion;
|
||||
assert.equal(
|
||||
registry.listAgents().find((entry) => entry.id === failedFeature.id).state,
|
||||
"streaming",
|
||||
);
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/while Pi is working/,
|
||||
/runtimes are open/,
|
||||
);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
await registry.closeSessionRuntime(failedFeature.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
await registry.stop();
|
||||
});
|
||||
@@ -389,6 +410,7 @@ test("catalogues managed directories and exposes only their sessions", async ()
|
||||
}),
|
||||
/ENOENT|belong/,
|
||||
);
|
||||
await registry.closeSessionRuntime(feature.runtimeId);
|
||||
await registry.stop();
|
||||
|
||||
const restored = createAgentRegistry({
|
||||
@@ -444,11 +466,17 @@ test("routes commands by explicit agent ID and replays only events after the cur
|
||||
command: "get_commands",
|
||||
success: true,
|
||||
});
|
||||
await registry.route(agent.id, "set_session_name", { name: "Release UI" });
|
||||
await registry.route(agent.id, "compact", {
|
||||
customInstructions: "Preserve implementation details",
|
||||
});
|
||||
assert.deepEqual(adapter.sent, [
|
||||
{ type: "get_state" },
|
||||
{ type: "prompt", message: "Stay in this worktree" },
|
||||
{ type: "get_session_stats" },
|
||||
{ type: "get_commands" },
|
||||
{ type: "set_session_name", name: "Release UI" },
|
||||
{ type: "compact", customInstructions: "Preserve implementation details" },
|
||||
]);
|
||||
|
||||
options.onEvent({
|
||||
|
||||
@@ -18,11 +18,16 @@ function connect(socketPath) {
|
||||
function request(socket, frame) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const onError = (error) => {
|
||||
socket.off("data", onData);
|
||||
reject(error);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk;
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline === -1) return;
|
||||
socket.off("data", onData);
|
||||
socket.off("error", onError);
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
@@ -30,7 +35,7 @@ function request(socket, frame) {
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
socket.once("error", onError);
|
||||
socket.write(`${JSON.stringify(frame)}\n`);
|
||||
});
|
||||
}
|
||||
@@ -89,6 +94,32 @@ test("starts the home agent and dispatches local protocol requests to the select
|
||||
payload: { worktreePath: feature },
|
||||
});
|
||||
const featureAgent = selected.result.agent;
|
||||
const workspace = await request(socket, {
|
||||
version: "v1",
|
||||
id: "workspace-1",
|
||||
op: "get_workspace",
|
||||
});
|
||||
const summary = await request(socket, {
|
||||
version: "v1",
|
||||
id: "summary-1",
|
||||
op: "get_workspace_summary",
|
||||
});
|
||||
assert.equal(typeof workspace.result.bridgeInstanceId, "string");
|
||||
assert.equal(workspace.result.directories.length, 2);
|
||||
assert.equal(summary.result.openCount, 2);
|
||||
assert.equal(summary.result.directoryCount, 2);
|
||||
assert.equal(summary.result.resourceWarning, false);
|
||||
const snapshot = await request(socket, {
|
||||
version: "v1",
|
||||
id: "snapshot-1",
|
||||
op: "get_session_runtime_snapshot",
|
||||
payload: { runtimeId: featureAgent.runtimeId },
|
||||
});
|
||||
assert.equal(snapshot.result.runtime.runtimeId, featureAgent.runtimeId);
|
||||
assert.equal(
|
||||
snapshot.result.bridgeInstanceId,
|
||||
workspace.result.bridgeInstanceId,
|
||||
);
|
||||
const prompt = await request(socket, {
|
||||
version: "v1",
|
||||
id: "prompt-1",
|
||||
@@ -98,18 +129,48 @@ test("starts the home agent and dispatches local protocol requests to the select
|
||||
});
|
||||
|
||||
assert.equal(prompt.ok, true);
|
||||
const named = await request(socket, {
|
||||
version: "v1",
|
||||
id: "name-1",
|
||||
op: "set_session_name",
|
||||
agentId: featureAgent.id,
|
||||
payload: { name: "Feature work" },
|
||||
});
|
||||
const compacted = await request(socket, {
|
||||
version: "v1",
|
||||
id: "compact-1",
|
||||
op: "compact",
|
||||
agentId: featureAgent.id,
|
||||
payload: { customInstructions: "Preserve tests" },
|
||||
});
|
||||
assert.equal(named.ok, true);
|
||||
assert.equal(compacted.ok, true);
|
||||
assert.deepEqual(
|
||||
fixture.calls.map(({ options }) => options.cwd),
|
||||
[home, feature],
|
||||
);
|
||||
assert.deepEqual(fixture.calls[1].adapter.sent, [
|
||||
{ type: "get_state" },
|
||||
{ type: "get_state" },
|
||||
{ type: "get_session_stats" },
|
||||
{ type: "get_messages" },
|
||||
{ type: "get_commands" },
|
||||
{ type: "get_available_models" },
|
||||
{ type: "prompt", message: "Use this worktree" },
|
||||
{ type: "set_session_name", name: "Feature work" },
|
||||
{ type: "compact", customInstructions: "Preserve tests" },
|
||||
]);
|
||||
fixture.calls[1].options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
const closed = await request(socket, {
|
||||
version: "v1",
|
||||
id: "close-1",
|
||||
op: "close_session_runtime",
|
||||
payload: { runtimeId: featureAgent.runtimeId },
|
||||
});
|
||||
assert.equal(closed.ok, true);
|
||||
|
||||
const forgotten = await request(socket, {
|
||||
version: "v1",
|
||||
|
||||
@@ -10,64 +10,24 @@ test("starts a bridge daemon around the local bridge service", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const daemon = await startBridgeDaemon({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: () => ({
|
||||
send: async () => ({ type: "response", success: true }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
assert.match(daemon.socketPath, /bridge\.sock$/);
|
||||
assert.equal(daemon.listAgents()[0].worktreePath, home);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
const daemon = await startBridgeDaemon({ homeWorktree: home, runtimeDir: join(root, "runtime"), sessionRoot: join(root, "sessions"), startAdapter: () => ({ send: async () => ({ type: "response", success: true }), respondToExtension: () => {}, stop: async () => {} }) });
|
||||
try { assert.match(daemon.socketPath, /bridge\.sock$/); assert.equal(daemon.listAgents()[0].worktreePath, home); }
|
||||
finally { await daemon.close(); }
|
||||
});
|
||||
|
||||
test("relays only the selected agent's bridge state into a presentation update", async () => {
|
||||
let listener;
|
||||
test("relays aggregate workspace summary as compact Noctalia state", async () => {
|
||||
const updates = [];
|
||||
const relay = createNoctaliaStateRelay({
|
||||
client: {
|
||||
request: async () => ({ data: { isStreaming: false } }),
|
||||
subscribe: async (_agentId, _cursor, callback) => {
|
||||
listener = callback;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
agent: { id: "agent-1", worktreePath: "/worktrees/feature" },
|
||||
onState: (state) => updates.push(state),
|
||||
client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } },
|
||||
onState: (state) => updates.push(state), pollMs: 250,
|
||||
});
|
||||
|
||||
await relay.start();
|
||||
listener({ type: "agent_state", data: { state: "streaming" } });
|
||||
listener({
|
||||
type: "queue",
|
||||
data: { event: { steering: ["focus"], followUp: [] } },
|
||||
});
|
||||
|
||||
assert.deepEqual(updates, [
|
||||
{
|
||||
state: "idle",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 0,
|
||||
detail: "Idle",
|
||||
},
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 0,
|
||||
detail: "Streaming",
|
||||
},
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 1,
|
||||
detail: "Streaming",
|
||||
},
|
||||
]);
|
||||
await relay.start(); relay.stop();
|
||||
assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]);
|
||||
});
|
||||
|
||||
test("retains agent-scoped relay behavior only when explicitly requested", async () => {
|
||||
let listener; const updates = [];
|
||||
const relay = createNoctaliaStateRelay({ client: { request: async () => ({ data: { isStreaming: false } }), subscribe: async (_agentId, _cursor, callback) => { listener = callback; return () => {}; } }, agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, onState: (state) => updates.push(state) });
|
||||
await relay.start(); listener({ type: "agent_state", data: { state: "streaming" } });
|
||||
assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature");
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,33 +2,18 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { publishNoctaliaState } from "../src/client/noctalia-ipc.js";
|
||||
|
||||
test("publishes presentation state through Noctalia's documented IPC command", async () => {
|
||||
test("publishes aggregate presentation state through Noctalia's documented IPC command", async () => {
|
||||
const calls = [];
|
||||
await publishNoctaliaState(
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 2,
|
||||
detail: "Streaming",
|
||||
state: "streaming", projectLabel: "3 sessions", attentionCount: 2,
|
||||
detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors",
|
||||
openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1,
|
||||
},
|
||||
{ execute: async (command, args) => calls.push({ command, args }) },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: "qs",
|
||||
args: [
|
||||
"-c",
|
||||
"noctalia-shell",
|
||||
"ipc",
|
||||
"call",
|
||||
"plugin:pi-status-bridge",
|
||||
"setBridgeState",
|
||||
"streaming",
|
||||
"feature",
|
||||
"2",
|
||||
"Streaming",
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(calls, [{
|
||||
command: "qs",
|
||||
args: ["-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState", "streaming", "3 sessions", "2", "3 open · 1 working · 2 attention · 0 recovering · 1 errors", "3", "1", "0", "1"],
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("ships a Noctalia plugin with compact bar and presentation-only panel entry points", async () => {
|
||||
test("ships a Noctalia aggregate launcher without a primary panel", async () => {
|
||||
const manifestText = await readFile(
|
||||
"noctalia-plugin/pi-status-bridge/manifest.json",
|
||||
"utf8",
|
||||
@@ -15,9 +15,8 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
|
||||
error instanceof Error ? error.message : "manifest was not valid JSON",
|
||||
);
|
||||
}
|
||||
const [bar, panel, main] = await Promise.all([
|
||||
const [bar, main] = await Promise.all([
|
||||
readFile("noctalia-plugin/pi-status-bridge/BarWidget.qml", "utf8"),
|
||||
readFile("noctalia-plugin/pi-status-bridge/Panel.qml", "utf8"),
|
||||
readFile("noctalia-plugin/pi-status-bridge/Main.qml", "utf8"),
|
||||
]);
|
||||
|
||||
@@ -25,10 +24,18 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
|
||||
assert.deepEqual(manifest.entryPoints, {
|
||||
main: "Main.qml",
|
||||
barWidget: "BarWidget.qml",
|
||||
panel: "Panel.qml",
|
||||
});
|
||||
assert.match(bar, /pluginApi\.togglePanel\(root\.screen, visualCapsule\)/);
|
||||
assert.match(panel, /allowAttach: true/);
|
||||
assert.match(bar, /systemctl", "--user", "start", "pi-status-bridge\.service/);
|
||||
assert.match(bar, /PI_STATUS_UI_BINARY/);
|
||||
assert.match(bar, /pi-status-ui/);
|
||||
assert.match(bar, /setsid -f env TMPDIR=\/tmp/);
|
||||
assert.match(bar, /--toggle/);
|
||||
assert.doesNotMatch(bar, /togglePanel/);
|
||||
assert.doesNotMatch(bar, /Panel\.qml/);
|
||||
assert.match(main, /IpcHandler/);
|
||||
assert.match(main, /setBridgeState/);
|
||||
assert.match(main, /openCount/);
|
||||
assert.match(main, /workingCount/);
|
||||
assert.match(main, /recoveringCount/);
|
||||
assert.match(main, /errorCount/);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => {
|
||||
test("ships a v5 Noctalia aggregate launcher instead of a primary panel", async () => {
|
||||
const [manifest, bridge] = await Promise.all([
|
||||
readFile("noctalia-v5-plugin/pi-status-bridge/plugin.toml", "utf8"),
|
||||
readFile("noctalia-v5-plugin/pi-status-bridge/bridge.luau", "utf8"),
|
||||
@@ -19,10 +19,21 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as
|
||||
assert.doesNotMatch(bridge, /--show/);
|
||||
assert.match(bridge, /pi-status-ui\.log/);
|
||||
assert.doesNotMatch(bridge, /togglePanel/);
|
||||
assert.match(bridge, /get_workspace_summary/);
|
||||
assert.match(bridge, /openCount/);
|
||||
assert.match(bridge, /workingCount/);
|
||||
assert.match(bridge, /attentionCount/);
|
||||
assert.match(bridge, /recoveringCount/);
|
||||
assert.match(bridge, /errorCount/);
|
||||
assert.match(bridge, /Pi reconnecting/);
|
||||
assert.match(bridge, /discover_home_agent\(socket\)/);
|
||||
assert.doesNotMatch(bridge, /discover_home_agent/);
|
||||
assert.doesNotMatch(bridge, /get_state/);
|
||||
assert.match(bridge, /systemctl --user start pi-status-bridge\.service/);
|
||||
assert.match(bridge, /bridge_start_in_flight/);
|
||||
assert.match(bridge, /pending_ui_open/);
|
||||
assert.match(bridge, /Bridge is starting/);
|
||||
await assert.rejects(
|
||||
access("noctalia-v5-plugin/pi-status-bridge/picker.luau"),
|
||||
{ code: "ENOENT" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -115,6 +115,40 @@ test("requests Pi session statistics for context and token status", async () =>
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("forwards native session naming and compaction commands", async () => {
|
||||
const fixture = createFixture();
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
});
|
||||
|
||||
const named = adapter.send({ type: "set_session_name", name: "Release UI" });
|
||||
const [nameCommand] = fixture.sent();
|
||||
assert.equal(nameCommand.type, "set_session_name");
|
||||
assert.equal(nameCommand.name, "Release UI");
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "response", id: nameCommand.id, command: "set_session_name", success: true })}\n`,
|
||||
);
|
||||
await named;
|
||||
|
||||
const compacted = adapter.send({
|
||||
type: "compact",
|
||||
customInstructions: "Preserve implementation details",
|
||||
});
|
||||
const [, compactCommand] = fixture.sent();
|
||||
assert.equal(compactCommand.type, "compact");
|
||||
assert.equal(
|
||||
compactCommand.customInstructions,
|
||||
"Preserve implementation details",
|
||||
);
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "response", id: compactCommand.id, command: "compact", success: true })}\n`,
|
||||
);
|
||||
await compacted;
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("forwards extension responses without replacing Pi's request ID", async () => {
|
||||
const fixture = createFixture();
|
||||
const events = [];
|
||||
|
||||
@@ -108,6 +108,98 @@ test("accepts bridge-managed directory and session operations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts additive multi-session runtime operations and numeric workspace cursors", () => {
|
||||
const worktreePath = "/home/alex/project";
|
||||
const sessionPath = "/home/alex/project/sessions/one.jsonl";
|
||||
for (const value of [
|
||||
{ op: "get_workspace" },
|
||||
{ op: "get_workspace_summary" },
|
||||
{ op: "create_session_runtime", payload: { worktreePath } },
|
||||
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
||||
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
||||
{ op: "list_directory_sessions", payload: { worktreePath } },
|
||||
{
|
||||
op: "get_session_runtime_snapshot",
|
||||
payload: { runtimeId: "runtime-1" },
|
||||
},
|
||||
{ op: "subscribe_workspace", payload: { cursor: 42 } },
|
||||
])
|
||||
assert.equal(parseRequestFrame(request(value)).op, value.op);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "subscribe_workspace",
|
||||
payload: { cursor: "opaque" },
|
||||
}),
|
||||
),
|
||||
/cursor must be a non-negative integer/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts native session naming and manual compaction requests", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: "Release UI" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: "Release UI" },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "compact", agentId: "agent-main" })),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
payload: { customInstructions: "Preserve implementation details" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
payload: { customInstructions: "Preserve implementation details" },
|
||||
},
|
||||
);
|
||||
for (const invalidRequest of [
|
||||
request({
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: " " },
|
||||
}),
|
||||
request({
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: "Valid", extra: true },
|
||||
}),
|
||||
request({
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
payload: { customInstructions: " " },
|
||||
}),
|
||||
request({ op: "compact", agentId: "agent-main", payload: { extra: true } }),
|
||||
])
|
||||
assert.throws(() => parseRequestFrame(invalidRequest), ProtocolError);
|
||||
});
|
||||
|
||||
test("accepts an agent session-statistics request", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
|
||||
@@ -42,6 +42,56 @@ function collectFrames(socket, count) {
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace subscription exposes daemon identity, replay boundaries, and globally ordered live events", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-workspace-subscribe-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const calls = [];
|
||||
const service = await startBridgeService({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
send: async () => ({ type: "response", success: true, data: {} }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
};
|
||||
calls.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
const socket = await connect(service.socketPath);
|
||||
|
||||
try {
|
||||
const frames = collectFrames(socket, 2);
|
||||
socket.write(
|
||||
`${JSON.stringify({ version: "v1", id: "workspace-1", op: "subscribe_workspace", payload: { cursor: 0 } })}\n`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
calls[0].options.onEvent({
|
||||
type: "stream",
|
||||
data: { event: { type: "message_update", delta: "live" } },
|
||||
});
|
||||
}, 10);
|
||||
const [replay, live] = await frames;
|
||||
assert.equal(replay.ok, true);
|
||||
assert.equal(typeof replay.result.bridgeInstanceId, "string");
|
||||
assert.equal(replay.result.truncated, false);
|
||||
assert.equal(Number.isSafeInteger(replay.result.firstAvailableSeq), true);
|
||||
assert.equal(Number.isSafeInteger(replay.result.latestSeq), true);
|
||||
assert.ok(replay.result.events.length >= 1);
|
||||
assert.equal(live.type, "event");
|
||||
assert.equal(live.event.bridgeInstanceId, replay.result.bridgeInstanceId);
|
||||
assert.equal(live.event.type, "runtime_event");
|
||||
assert.equal(live.event.data.eventType, "stream");
|
||||
assert.ok(live.event.seq > replay.result.events.at(-1).seq);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await service.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays then streams subscribed agent events on the same local socket", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-subscribe-"));
|
||||
const home = join(root, "home");
|
||||
|
||||
+69
-370
@@ -2,385 +2,84 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => {
|
||||
const source = await readFile("ui/src/App.tsx", "utf8");
|
||||
for (const method of [
|
||||
"select",
|
||||
"confirm",
|
||||
"input",
|
||||
"editor",
|
||||
"notify",
|
||||
"setStatus",
|
||||
"setWidget",
|
||||
"setTitle",
|
||||
"set_editor_text",
|
||||
]) {
|
||||
assert.match(source, new RegExp(`case "${method}"`));
|
||||
}
|
||||
assert.match(source, /setPendingExtension\(extension\)/);
|
||||
assert.doesNotMatch(source, /This extension UI is unsupported/);
|
||||
assert.match(source, /function stripAnsi/);
|
||||
assert.match(source, /stripAnsi\(extension\.statusText\)/);
|
||||
assert.match(source, /className="pi-controls"/);
|
||||
assert.match(source, /aria-label="Model and thinking controls"/);
|
||||
assert.match(source, /const uiScaleKey/);
|
||||
assert.match(source, /function clampUiScale/);
|
||||
assert.match(source, /function updateUiScale/);
|
||||
assert.match(source, /localStorage\.setItem\(uiScaleKey/);
|
||||
assert.match(source, /className="scale-settings"/);
|
||||
assert.match(source, /aria-label="Interface scale"/);
|
||||
assert.match(source, /aria-label="Decrease interface scale"/);
|
||||
assert.match(source, /aria-label="Increase interface scale"/);
|
||||
assert.match(source, /type="range"/);
|
||||
assert.match(source, /style=\{\{ zoom: uiScale \}\}/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/className="pi-controls"[\s\S]*aria-label="Interface scale"/,
|
||||
);
|
||||
assert.doesNotMatch(source, /<details className="pi-controls">/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/,
|
||||
);
|
||||
assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/);
|
||||
assert.match(
|
||||
source,
|
||||
/window\.addEventListener\("keydown", handleKeydown, true\)/,
|
||||
);
|
||||
assert.match(source, /useLayoutEffect/);
|
||||
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
|
||||
assert.match(source, /requestAnimationFrame\(scrollTranscriptToLatest\)/);
|
||||
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/const bridgeRetryTimer = useRef<number \| undefined>\(undefined\)/,
|
||||
);
|
||||
assert.match(source, /const bridgeRetryAttempt = useRef\(0\)/);
|
||||
assert.match(source, /function scheduleBridgeReconnect/);
|
||||
assert.match(source, /Bridge unavailable; retrying in/);
|
||||
assert.match(
|
||||
source,
|
||||
/setStatus\(String\(error\)\);\s*scheduleBridgeReconnect\(\);/,
|
||||
);
|
||||
assert.match(source, /bridgeRetryTimer\.current = window\.setTimeout/);
|
||||
assert.match(source, /void refreshAgents\(preferredPath\)/);
|
||||
assert.match(source, /bridgeRetryAttempt\.current = 0/);
|
||||
assert.match(
|
||||
source,
|
||||
/const lastEventSequence = useRef<Record<string, number>>/,
|
||||
);
|
||||
assert.match(source, /const eventReloadInFlight = useRef\(false\)/);
|
||||
assert.match(source, /const queuedEventReload = useRef<string \| undefined>/);
|
||||
assert.match(source, /function reloadAgentAfterEvent\(agentId: string\)/);
|
||||
assert.match(source, /void reloadAgentAfterEvent\(selectedId\)/);
|
||||
assert.match(source, /let disposed = false;/);
|
||||
assert.match(source, /if \(disposed\) stop\(\);/);
|
||||
assert.match(source, /generation !== loadGeneration\.current/);
|
||||
assert.match(source, /current\.phase !== "working"/);
|
||||
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
|
||||
assert.match(source, /\.toFixed\(1\)/);
|
||||
assert.match(source, /formatTokens\(stats\.tokens\.input\)/);
|
||||
assert.match(source, /formatTokens\(stats\.tokens\.output\)/);
|
||||
assert.match(source, /formatTokens\(cacheTokens\)/);
|
||||
assert.match(source, /function updateWorkProgress/);
|
||||
assert.match(
|
||||
source,
|
||||
/className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
assert.match(source, /event\.type === "tool"/);
|
||||
assert.match(source, /event\.type === "queue"/);
|
||||
assert.match(source, /composer-progress/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/<section className=\{`work-progress \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
const sources = Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/workspace/reducer.ts", "utf8"),
|
||||
readFile("ui/src/workspace/useWorkspace.ts", "utf8"),
|
||||
readFile("ui/src/workspace/persistence.ts", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
|
||||
test("wires the desktop to stable runtime workspace APIs", async () => {
|
||||
const [app, reducer, hook] = await sources;
|
||||
for (const operation of [
|
||||
"create_session_runtime",
|
||||
"open_session_runtime",
|
||||
"close_session_runtime",
|
||||
"list_directory_sessions",
|
||||
"get_session_runtime_snapshot",
|
||||
"subscribe_workspace",
|
||||
])
|
||||
assert.match(`${app}\n${hook}`, new RegExp(operation));
|
||||
assert.doesNotMatch(app, /switch_session/);
|
||||
assert.doesNotMatch(app, /new_session/);
|
||||
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
|
||||
assert.match(reducer, /requestGeneration/);
|
||||
assert.match(reducer, /loadedThroughSeq/);
|
||||
});
|
||||
|
||||
test("exposes accessible directory, session, notification, and extension controls", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
test("keeps presentation persistence allowlisted and agent IDs ephemeral", async () => {
|
||||
const [, , , persistence] = await sources;
|
||||
assert.match(persistence, /load_ui_state/);
|
||||
assert.match(persistence, /save_ui_state/);
|
||||
assert.match(persistence, /directoryOrder/);
|
||||
assert.match(persistence, /drafts/);
|
||||
assert.doesNotMatch(persistence, /agentId/);
|
||||
assert.doesNotMatch(persistence, /messages/);
|
||||
assert.doesNotMatch(persistence, /extensions/);
|
||||
});
|
||||
|
||||
test("renders directory sidebar, accessible session tabs, warnings, and reduced motion", async () => {
|
||||
const [app, , , , css] = await sources;
|
||||
const [sidebar, tabs] = await Promise.all([
|
||||
readFile("ui/src/components/DirectorySidebar.tsx", "utf8"),
|
||||
readFile("ui/src/components/SessionTabs.tsx", "utf8"),
|
||||
]);
|
||||
assert.match(component, /aria-expanded=\{view === "settings"\}/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-label="Managed directories"\s+className="directory-tab-list"\s+role="tablist"/,
|
||||
);
|
||||
assert.match(component, /role="tab"/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-selected=\{directory\.agentId === selectedId\}/,
|
||||
);
|
||||
assert.match(component, /aria-controls="directory-workspace"/);
|
||||
assert.match(component, /onKeyDown=\{\(event\) => handleDirectoryTabKeyDown/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-current=\{session\.isCurrent \? "page" : undefined\}/,
|
||||
);
|
||||
assert.match(component, /role="status" aria-atomic="true"/);
|
||||
assert.match(component, /aria-hidden="true"/);
|
||||
assert.match(
|
||||
component,
|
||||
/role=\{notice\.notifyType === "error" \? "alert" : "status"\}/,
|
||||
);
|
||||
assert.match(component, /role="dialog"/);
|
||||
assert.match(component, /aria-modal="true"/);
|
||||
assert.match(component, /aria-labelledby="extension-title"/);
|
||||
assert.match(component, /const extensionRef = useRef<HTMLElement>\(null\)/);
|
||||
assert.match(component, /function focusableExtensionControls/);
|
||||
assert.match(component, /event\.key !== "Tab"/);
|
||||
assert.match(component, /data-initial-focus/);
|
||||
assert.match(component, /className="extension-backdrop"/);
|
||||
assert.match(component, /htmlFor="extension-value"/);
|
||||
assert.match(component, /Close current session and start a new session/);
|
||||
assert.match(component, /Restart Pi for the selected directory/);
|
||||
assert.match(component, /window\.confirm\(/);
|
||||
assert.match(styles, /button:focus-visible/);
|
||||
assert.match(styles, /\*::before,\n\*::after/);
|
||||
assert.match(styles, /\.sr-only/);
|
||||
assert.match(styles, /\.extension-backdrop \{/);
|
||||
assert.match(styles, /animation-duration: 0\.01ms/);
|
||||
assert.match(sidebar, /aria-label="Directories"/);
|
||||
assert.match(tabs, /role="tablist"/);
|
||||
assert.match(tabs, /role="tab"/);
|
||||
assert.match(tabs, /aria-selected/);
|
||||
assert.match(tabs, /ArrowRight/);
|
||||
assert.match(app, /Multiple sessions are open in the same directory/);
|
||||
assert.match(app, /Many Pi sessions are open/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
|
||||
});
|
||||
|
||||
test("makes the window draggable from the non-interactive header", async () => {
|
||||
const [component, styles, capability] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
|
||||
test("preserves native session naming, compaction, composer, and extension responses", async () => {
|
||||
const [app] = await sources;
|
||||
const [conversation, extension] = await Promise.all([
|
||||
readFile("ui/src/components/ConversationWorkspace.tsx", "utf8"),
|
||||
readFile("ui/src/components/ExtensionDialog.tsx", "utf8"),
|
||||
]);
|
||||
assert.match(component, /startDragging\(\)/);
|
||||
assert.match(
|
||||
component,
|
||||
/closest\(\s*"button, input, select, textarea",?\s*\)/,
|
||||
);
|
||||
assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/);
|
||||
assert.match(capability, /core:window:allow-start-dragging/);
|
||||
assert.match(app, /set_session_name/);
|
||||
assert.match(app, /compact/);
|
||||
assert.match(app, /submit_prompt/);
|
||||
assert.match(app, /respond_to_extension/);
|
||||
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.match(conversation, /className="todos-pane"/);
|
||||
assert.match(extension, /role="dialog"/);
|
||||
assert.match(extension, /aria-modal="true"/);
|
||||
});
|
||||
|
||||
test("opens local follow-up choices for /model and /resume", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /const controlCommands: Command\[\]/);
|
||||
assert.match(component, /followUp: "model"/);
|
||||
assert.match(component, /followUp: "resume"/);
|
||||
assert.match(component, /followUp: "thinking"/);
|
||||
assert.match(component, /const nativeCommand = controlCommands\.find/);
|
||||
assert.match(component, /function chooseThinking/);
|
||||
assert.match(component, /function openCommandFollowUp/);
|
||||
assert.match(component, /function chooseModel/);
|
||||
assert.match(component, /className="command-followup"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(styles, /\.command-followup \{/);
|
||||
assert.match(styles, /\.command-choice-list \{/);
|
||||
});
|
||||
|
||||
test("shows bridge-managed directory status and sessions for the opened directory", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(
|
||||
component,
|
||||
/invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/,
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
|
||||
);
|
||||
assert.match(component, /function chooseDirectory/);
|
||||
assert.match(component, /function handleDirectoryTabKeyDown/);
|
||||
assert.match(component, /function forgetDirectory/);
|
||||
assert.match(component, /window\.confirm/);
|
||||
assert.match(component, /"forget_directory"/);
|
||||
assert.match(component, /bridgeEvent\.type === "agent_state"/);
|
||||
assert.match(component, /setDirectories\(\(current\) =>/);
|
||||
assert.match(
|
||||
component,
|
||||
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
|
||||
);
|
||||
assert.match(component, /className="directory-add"/);
|
||||
assert.match(
|
||||
component,
|
||||
/import \{ open \} from "@tauri-apps\/plugin-dialog"/,
|
||||
);
|
||||
assert.match(component, /function browseForFolder/);
|
||||
assert.match(component, /directory: true/);
|
||||
assert.match(component, /multiple: false/);
|
||||
assert.match(component, /Browse…/);
|
||||
assert.match(component, /const \[isAddingDirectory, setIsAddingDirectory\]/);
|
||||
assert.match(component, /aria-busy=\{isAddingDirectory\}/);
|
||||
assert.match(component, /className="directory-add-status" role="status"/);
|
||||
assert.match(component, /pendingDirectoryPath/);
|
||||
assert.match(component, /Added \$\{worktreeLabel\(pendingDirectoryPath\)\}/);
|
||||
assert.match(component, /Add directory/);
|
||||
assert.doesNotMatch(component, /className="folder-form"/);
|
||||
assert.doesNotMatch(component, /Remembered folders/);
|
||||
assert.match(component, /function startNewSession/);
|
||||
assert.match(component, /function closeCurrentSession/);
|
||||
assert.match(component, /Use \/resume to reopen it/);
|
||||
assert.match(component, /function switchSession/);
|
||||
assert.match(component, /className="session-panel"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(component, /state\.isStreaming/);
|
||||
assert.match(styles, /\.directory-add \{/);
|
||||
assert.match(styles, /\.directory-add-status \{/);
|
||||
assert.match(styles, /\.directory-add-spinner \{/);
|
||||
assert.match(styles, /directory-add-spin/);
|
||||
assert.match(styles, /\.directory-tab \{/);
|
||||
assert.match(styles, /\.directory-tab-button \{/);
|
||||
assert.match(styles, /\.directory-close \{/);
|
||||
assert.match(styles, /\.session-panel \{/);
|
||||
assert.match(styles, /\.session-list \{/);
|
||||
});
|
||||
|
||||
test("renders todo-plugin state and compact context/token status", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /function currentTodos/);
|
||||
assert.match(component, /message\.toolName === "todo"/);
|
||||
assert.match(component, /className="todos-pane"/);
|
||||
assert.match(component, /className="workflow-main"/);
|
||||
assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/);
|
||||
assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/);
|
||||
assert.match(styles, /\.workflow-main \{/);
|
||||
assert.match(styles, /\.todos-pane \{/);
|
||||
assert.match(styles, /\.status-metric \{/);
|
||||
});
|
||||
|
||||
test("shows an optimistic prompt until the transcript receives it", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /type PendingSubmission/);
|
||||
assert.match(
|
||||
component,
|
||||
/const \[pendingSubmissions, setPendingSubmissions\]/,
|
||||
);
|
||||
assert.match(component, /setStatus\("Sending prompt to Pi…"\)/);
|
||||
assert.match(component, /phase: "sending"/);
|
||||
assert.match(component, /phase: "sent"/);
|
||||
assert.match(component, /Sent · waiting for Pi/);
|
||||
assert.match(component, /receivedMessages\.some/);
|
||||
assert.match(component, /pending-message/);
|
||||
assert.match(styles, /\.pending-message \{/);
|
||||
assert.match(styles, /\.pending-message\.sending/);
|
||||
});
|
||||
|
||||
test("offers a lower-center control when the conversation is above its latest message", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(
|
||||
component,
|
||||
/const \[isTranscriptAtBottom, setIsTranscriptAtBottom\]/,
|
||||
);
|
||||
assert.match(component, /function updateTranscriptScrollPosition/);
|
||||
assert.match(component, /function scrollTranscriptToLatest/);
|
||||
assert.match(component, /onScroll=\{updateTranscriptScrollPosition\}/);
|
||||
assert.match(component, /!isTranscriptAtBottom/);
|
||||
assert.match(component, /aria-label="Scroll to latest message"/);
|
||||
assert.match(component, /className="scroll-to-latest"/);
|
||||
assert.match(component, /scrollTranscriptToLatest\(\);/);
|
||||
assert.match(styles, /\.transcript-container \{/);
|
||||
assert.match(styles, /\.scroll-to-latest \{[\s\S]*left: 50%/);
|
||||
assert.match(styles, /\.scroll-to-latest \{[\s\S]*bottom: 14px/);
|
||||
assert.match(styles, /\.scroll-to-latest \{[\s\S]*border-radius: 50%/);
|
||||
});
|
||||
|
||||
test("distinguishes user and assistant messages", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.message\.user \{/);
|
||||
assert.match(styles, /background: #192536/);
|
||||
assert.match(styles, /\.message\.assistant \{/);
|
||||
assert.match(styles, /background: #1a241a/);
|
||||
});
|
||||
|
||||
test("keeps an accessible focus treatment for the prompt editor", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.composer textarea:focus \{/);
|
||||
assert.doesNotMatch(styles, /outline: none/);
|
||||
assert.match(styles, /border-color: #6f8b56/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.composer textarea:focus \{[^}]*outline: 2px solid #c4ed8b/,
|
||||
);
|
||||
assert.match(styles, /\.composer textarea:focus \{[^}]*outline-offset: -2px/);
|
||||
assert.doesNotMatch(styles, /\.composer textarea:focus \{[^}]*box-shadow:/);
|
||||
assert.match(styles, /textarea:focus-visible/);
|
||||
});
|
||||
|
||||
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
|
||||
const source = await readFile("ui/src/App.tsx", "utf8");
|
||||
assert.match(source, /onKeyDown=\{\(event\) => \{/);
|
||||
assert.match(source, /event\.key !== "Enter"/);
|
||||
assert.match(source, /event\.shiftKey/);
|
||||
assert.match(source, /event\.nativeEvent\.isComposing/);
|
||||
assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/);
|
||||
assert.match(source, /Enter to send · Shift\+Enter for newline/);
|
||||
});
|
||||
|
||||
test("uses a compact workflow layout that keeps the composer and transcript stable", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /className="workflow-header"/);
|
||||
assert.match(component, /className="workflow"/);
|
||||
assert.match(component, /className="workflow-footer"/);
|
||||
assert.match(component, /className="composer-stack"/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/,
|
||||
);
|
||||
assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/);
|
||||
assert.match(
|
||||
component,
|
||||
/className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/,
|
||||
);
|
||||
assert.match(styles, /\.transcript\.working \{/);
|
||||
assert.match(styles, /conic-gradient\(/);
|
||||
assert.match(styles, /transcript-border-orbit/);
|
||||
assert.match(styles, /prefers-reduced-motion: reduce/);
|
||||
assert.match(styles, /font-size: 18px/);
|
||||
assert.match(styles, /\.directory-tab-button \{[\s\S]*font-size: 13px/);
|
||||
assert.match(styles, /\.message pre \{[\s\S]*font-size: 14px/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/,
|
||||
);
|
||||
assert.match(styles, /\.scale-settings \{/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/,
|
||||
);
|
||||
assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/);
|
||||
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
|
||||
});
|
||||
|
||||
test("configures a larger desktop window and enables the folder dialog", async () => {
|
||||
const [config, capability, rust] = await Promise.all([
|
||||
test("configures native window behavior and folder dialog capabilities", async () => {
|
||||
const [config, capability, cargo] = await Promise.all([
|
||||
readFile("ui/src-tauri/tauri.conf.json", "utf8"),
|
||||
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
|
||||
readFile("ui/src-tauri/src/lib.rs", "utf8"),
|
||||
readFile("ui/src-tauri/Cargo.toml", "utf8"),
|
||||
]);
|
||||
assert.match(config, /"width": 1100/);
|
||||
assert.match(config, /"height": 840/);
|
||||
assert.match(config, /"minWidth": 640/);
|
||||
assert.match(config, /"minHeight": 520/);
|
||||
assert.match(capability, /"dialog:default"/);
|
||||
assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/);
|
||||
assert.match(config, /"width"\s*:\s*1100/);
|
||||
assert.match(capability, /core:window:allow-start-dragging/);
|
||||
assert.match(cargo, /tauri-plugin-single-instance/);
|
||||
assert.match(cargo, /tauri-plugin-dialog/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createWorkspaceStore } from "../src/bridge/workspace-store.js";
|
||||
|
||||
test("persists an owner-only versioned workspace manifest atomically", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-store-"));
|
||||
const store = createWorkspaceStore(join(root, "sessions"));
|
||||
const first = await store.load();
|
||||
assert.equal(first.migrating, true);
|
||||
|
||||
await store.save({
|
||||
version: 2,
|
||||
migrated: true,
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: "runtime-1",
|
||||
worktreePath: join(root, "home"),
|
||||
sessionPath: join(root, "sessions", "agent", "one.jsonl"),
|
||||
sessionId: "session-1",
|
||||
},
|
||||
],
|
||||
});
|
||||
const metadata = await stat(store.filePath);
|
||||
assert.equal(metadata.mode & 0o777, 0o600);
|
||||
const restored = (await store.load()).manifest;
|
||||
assert.equal(restored.runtimes[0].runtimeId, "runtime-1");
|
||||
assert.deepEqual(restored.directories, [join(root, "home")]);
|
||||
assert.equal((await readFile(store.filePath, "utf8")).endsWith("\n"), true);
|
||||
});
|
||||
|
||||
test("accepts old manifests and persists managed empty directories", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-directories-"));
|
||||
const store = createWorkspaceStore(join(root, "sessions"));
|
||||
await store.save({
|
||||
version: 2,
|
||||
migrated: true,
|
||||
directories: [join(root, "empty")],
|
||||
runtimes: [],
|
||||
});
|
||||
assert.deepEqual((await store.load()).manifest.directories, [
|
||||
join(root, "empty"),
|
||||
]);
|
||||
await writeFile(
|
||||
store.filePath,
|
||||
JSON.stringify({ version: 2, migrated: true, runtimes: [] }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
assert.deepEqual((await store.load()).manifest.directories, []);
|
||||
});
|
||||
|
||||
test("reports a corrupt manifest without treating it as first migration", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-corrupt-"));
|
||||
const store = createWorkspaceStore(join(root, "sessions"));
|
||||
await store.save({ version: 2, migrated: true, runtimes: [] });
|
||||
await writeFile(store.filePath, "{broken", { mode: 0o600 });
|
||||
|
||||
const loaded = await store.load();
|
||||
assert.equal(loaded.migrating, false);
|
||||
assert.equal(loaded.manifest.runtimes.length, 0);
|
||||
assert.equal(loaded.issue.code, "invalid_workspace_manifest");
|
||||
});
|
||||
|
||||
test("recovers its serialized write queue after a failed save", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-retry-"));
|
||||
const sessionRoot = join(root, "sessions");
|
||||
const store = createWorkspaceStore(sessionRoot);
|
||||
await mkdir(sessionRoot);
|
||||
await mkdir(store.filePath);
|
||||
await assert.rejects(
|
||||
store.save({ version: 2, migrated: true, runtimes: [] }),
|
||||
);
|
||||
await rm(store.filePath, { recursive: true });
|
||||
await store.save({ version: 2, migrated: true, runtimes: [] });
|
||||
assert.deepEqual((await store.load()).manifest.runtimes, []);
|
||||
});
|
||||
Reference in New Issue
Block a user