feat(status-ui): improve bridge recovery and desktop controls
This commit is contained in:
+254
-2
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -72,6 +72,253 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("stops agents created during shutdown and rejects new selections", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
let releaseFeatureCreation;
|
||||
const featureCreation = new Promise((resolve) => {
|
||||
releaseFeatureCreation = resolve;
|
||||
});
|
||||
let featureCreationStarted;
|
||||
const featureStarted = new Promise((resolve) => {
|
||||
featureCreationStarted = resolve;
|
||||
});
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: (options) => {
|
||||
const adapter = fixture.startAdapter(options);
|
||||
if (options.cwd === worktrees.feature) {
|
||||
const send = adapter.send.bind(adapter);
|
||||
adapter.send = (command) => {
|
||||
if (command.type !== "get_state") return send(command);
|
||||
featureCreationStarted();
|
||||
return featureCreation.then(() => send(command));
|
||||
};
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
await registry.start();
|
||||
const selecting = registry.selectWorktree(worktrees.feature);
|
||||
await featureStarted;
|
||||
|
||||
const stopping = registry.stop();
|
||||
await assert.rejects(
|
||||
registry.selectWorktree(worktrees.feature),
|
||||
/registry is stopping/,
|
||||
);
|
||||
releaseFeatureCreation();
|
||||
await selecting;
|
||||
await stopping;
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("drains a forget still resolving its canonical path during shutdown", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const featureAlias = join(worktrees.root, "feature-alias");
|
||||
await symlink(worktrees.feature, featureAlias);
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
await registry.selectWorktree(worktrees.feature);
|
||||
const featureAdapter = fixture.calls[1].adapter;
|
||||
const stop = featureAdapter.stop.bind(featureAdapter);
|
||||
let stopCalls = 0;
|
||||
featureAdapter.stop = async () => {
|
||||
stopCalls += 1;
|
||||
await stop();
|
||||
};
|
||||
|
||||
const forgetting = registry.forgetDirectory(featureAlias);
|
||||
const stopping = registry.stop();
|
||||
await Promise.all([forgetting, stopping]);
|
||||
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(
|
||||
registry
|
||||
.listAgents()
|
||||
.some((agent) => agent.worktreePath === worktrees.feature),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("forgets a directory without deleting its resumable session history", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
const home = await registry.start();
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const sessionPath = join(feature.sessionDir, "resumable.jsonl");
|
||||
const sessionContent = `${JSON.stringify({
|
||||
type: "session",
|
||||
id: "resumable",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
cwd: worktrees.feature,
|
||||
})}\n`;
|
||||
await writeFile(sessionPath, sessionContent);
|
||||
await writeFile(
|
||||
join(feature.sessionDir, "bridge-agent.json"),
|
||||
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
|
||||
);
|
||||
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
assert.deepEqual(registry.listAgents(), [home]);
|
||||
assert.deepEqual(await registry.listDirectories(), [
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
isHome: true,
|
||||
agentId: home.id,
|
||||
},
|
||||
]);
|
||||
assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.home),
|
||||
/cannot forget the home directory/,
|
||||
);
|
||||
|
||||
const reopened = await registry.selectWorktree(worktrees.feature);
|
||||
assert.deepEqual(
|
||||
(await registry.listSessions(reopened.id)).map(({ id }) => id),
|
||||
["resumable"],
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("coordinates forgetting with directory creation and active commands", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
let releaseFeatureCreation;
|
||||
const featureCreation = new Promise((resolve) => {
|
||||
releaseFeatureCreation = resolve;
|
||||
});
|
||||
let featureCreationStarted;
|
||||
const featureStarted = new Promise((resolve) => {
|
||||
featureCreationStarted = resolve;
|
||||
});
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: (options) => {
|
||||
const adapter = fixture.startAdapter(options);
|
||||
if (options.cwd === worktrees.feature) {
|
||||
const send = adapter.send.bind(adapter);
|
||||
adapter.send = (command) => {
|
||||
if (command.type !== "get_state") return send(command);
|
||||
featureCreationStarted();
|
||||
return featureCreation.then(() => send(command));
|
||||
};
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
await registry.start();
|
||||
|
||||
const selecting = registry.selectWorktree(worktrees.feature);
|
||||
await featureStarted;
|
||||
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
|
||||
releaseFeatureCreation();
|
||||
await selecting;
|
||||
await forgettingDuringCreation;
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
assert.equal(
|
||||
(await registry.listDirectories()).some(
|
||||
(directory) => directory.worktreePath === worktrees.feature,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
await registry.selectWorktree(worktrees.feature);
|
||||
const concurrentAdapter = fixture.calls[2].adapter;
|
||||
const stop = concurrentAdapter.stop.bind(concurrentAdapter);
|
||||
let releaseStop;
|
||||
const stopGate = new Promise((resolve) => {
|
||||
releaseStop = resolve;
|
||||
});
|
||||
let stopCalls = 0;
|
||||
concurrentAdapter.stop = async () => {
|
||||
stopCalls += 1;
|
||||
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/,
|
||||
);
|
||||
releaseStop();
|
||||
await Promise.all([firstForget, secondForget]);
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(registry.listAgents().length, 1);
|
||||
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const { adapter, options } = fixture.calls[3];
|
||||
const send = adapter.send.bind(adapter);
|
||||
let releasePrompt;
|
||||
const promptResponse = new Promise((resolve) => {
|
||||
releasePrompt = resolve;
|
||||
});
|
||||
adapter.send = (command) =>
|
||||
command.type === "prompt" ? promptResponse : send(command);
|
||||
const prompt = registry.route(feature.id, "prompt", {
|
||||
message: "Keep working",
|
||||
});
|
||||
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
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.forgetDirectory(worktrees.feature);
|
||||
assert.equal(adapter.stopped, true);
|
||||
|
||||
const failedFeature = await registry.selectWorktree(worktrees.feature);
|
||||
const failedFixture = fixture.calls[4];
|
||||
let rejectPrompt;
|
||||
const failedPromptResponse = new Promise((_resolve, reject) => {
|
||||
rejectPrompt = reject;
|
||||
});
|
||||
const failedSend = failedFixture.adapter.send.bind(failedFixture.adapter);
|
||||
failedFixture.adapter.send = (command) =>
|
||||
command.type === "prompt" ? failedPromptResponse : failedSend(command);
|
||||
const failedPrompt = registry.route(failedFeature.id, "prompt", {
|
||||
message: "Start before the response fails",
|
||||
});
|
||||
const failedPromptAssertion = assert.rejects(failedPrompt, /send failed/);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "streaming" },
|
||||
});
|
||||
rejectPrompt(new Error("send failed"));
|
||||
await failedPromptAssertion;
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/while Pi is working/,
|
||||
);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("catalogues managed directories and exposes only their sessions", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
@@ -151,10 +398,15 @@ test("catalogues managed directories and exposes only their sessions", async ()
|
||||
});
|
||||
await restored.start();
|
||||
assert.deepEqual(await restored.listDirectories(), [
|
||||
{ worktreePath: worktrees.feature, state: "inactive" },
|
||||
{
|
||||
worktreePath: worktrees.feature,
|
||||
state: "inactive",
|
||||
isHome: false,
|
||||
},
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
isHome: true,
|
||||
agentId: restored.listAgents()[0].id,
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user