feat(status-ui): improve concurrent session workflows
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state. Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
This commit is contained in:
@@ -224,12 +224,10 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
|
||||
startAdapter,
|
||||
});
|
||||
const starting = restored.start();
|
||||
while (gates.length < 2)
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
while (gates.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(gates.length, 2);
|
||||
gates.splice(0).forEach((resolve) => resolve());
|
||||
while (gates.length < 1)
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
while (gates.length < 1) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(gates.length, 1);
|
||||
gates.splice(0).forEach((resolve) => resolve());
|
||||
await starting;
|
||||
@@ -290,11 +288,7 @@ test("serializes close behind an in-flight command and aborts the working runtim
|
||||
|
||||
test("forces a child kill and retains its lease until exit is confirmed", async () => {
|
||||
const paths = await fixture();
|
||||
const saved = await sessionFile(
|
||||
paths.sessionRoot,
|
||||
paths.home,
|
||||
"forced-close",
|
||||
);
|
||||
const saved = await sessionFile(paths.sessionRoot, paths.home, "forced-close");
|
||||
let killed;
|
||||
let releaseStop;
|
||||
let blockedSavedAdapter = false;
|
||||
@@ -445,6 +439,68 @@ test("legacy new session releases old identity and makes its history reopenable"
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("renews one runtime in place, aborts work, and clears its active identity", async () => {
|
||||
const paths = await fixture();
|
||||
const saved = await sessionFile(
|
||||
paths.sessionRoot,
|
||||
paths.home,
|
||||
"renew-me",
|
||||
"renew-me-id",
|
||||
);
|
||||
let currentState = {
|
||||
sessionFile: saved,
|
||||
sessionId: "renew-me-id",
|
||||
sessionName: "Renew me",
|
||||
};
|
||||
const adapters = adapterFactory({ stateFor: () => currentState });
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: paths.home,
|
||||
sessionRoot: paths.sessionRoot,
|
||||
startAdapter: adapters.startAdapter,
|
||||
});
|
||||
const original = await registry.start();
|
||||
const adapter = adapters.calls[0].adapter;
|
||||
const originalSend = adapter.send.bind(adapter);
|
||||
adapter.send = async (command) => {
|
||||
if (command.type === "new_session") currentState = {};
|
||||
return originalSend(command);
|
||||
};
|
||||
adapters.calls[0].options.onEvent({
|
||||
type: "extension_ui_request",
|
||||
data: { event: { id: "stale-renew-request", method: "confirm" } },
|
||||
});
|
||||
adapters.calls[0].options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "streaming" },
|
||||
});
|
||||
|
||||
const renewed = await registry.renewSession(original.id);
|
||||
const active = registry.getWorkspace().directories[0].runtimes[0];
|
||||
assert.equal(renewed.id, original.id);
|
||||
assert.equal(renewed.runtimeId, original.runtimeId);
|
||||
assert.equal(active.worktreePath, paths.home);
|
||||
assert.equal(active.sessionPath, undefined);
|
||||
assert.equal(active.sessionId, undefined);
|
||||
assert.equal(active.label, "New session");
|
||||
assert.equal(active.state, "idle");
|
||||
assert.equal(active.attention, false);
|
||||
assert.deepEqual(
|
||||
adapter.sent.slice(-3).map((command) => command.type),
|
||||
["abort", "new_session", "get_state"],
|
||||
);
|
||||
assert.equal(
|
||||
registry
|
||||
.workspaceEventsAfter(0)
|
||||
.events.some((event) => event.type === "runtime_renewed"),
|
||||
true,
|
||||
);
|
||||
|
||||
currentState = { sessionFile: saved, sessionId: "renew-me-id" };
|
||||
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
||||
assert.notEqual(reopened.runtimeId, original.runtimeId);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => {
|
||||
const paths = await fixture();
|
||||
const createdPath = await sessionFile(
|
||||
@@ -611,6 +667,28 @@ test("non-default runtime snapshots do not replace the legacy session pointer",
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("reports available-model RPC failures in runtime snapshots", async () => {
|
||||
const paths = await fixture();
|
||||
const adapters = adapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: paths.home,
|
||||
sessionRoot: paths.sessionRoot,
|
||||
startAdapter: adapters.startAdapter,
|
||||
});
|
||||
const runtime = await registry.start();
|
||||
const originalSend = adapters.calls[0].adapter.send;
|
||||
adapters.calls[0].adapter.send = async function (command) {
|
||||
if (command.type === "get_available_models")
|
||||
throw new Error("model registry unavailable");
|
||||
return originalSend.call(this, command);
|
||||
};
|
||||
|
||||
const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
||||
assert.deepEqual(snapshot.models.data.models, []);
|
||||
assert.match(snapshot.models.error, /model registry unavailable/);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("retains a corrupt workspace manifest and reports its issue", async () => {
|
||||
const paths = await fixture();
|
||||
await mkdir(paths.sessionRoot, { recursive: true });
|
||||
@@ -622,10 +700,7 @@ test("retains a corrupt workspace manifest and reports its issue", async () => {
|
||||
startAdapter: adapterFactory().startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
assert.equal(
|
||||
registry.getWorkspace().issue.code,
|
||||
"invalid_workspace_manifest",
|
||||
);
|
||||
assert.equal(registry.getWorkspace().issue.code, "invalid_workspace_manifest");
|
||||
assert.equal(await readFile(manifestPath, "utf8"), "{broken");
|
||||
await registry.stop();
|
||||
});
|
||||
@@ -1025,6 +1100,35 @@ test("manual restart ignores late extension dialogs from the replaced child", as
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("manual restart rejects exhausted recovery and marks the runtime failed", async () => {
|
||||
const paths = await fixture();
|
||||
const adapters = adapterFactory();
|
||||
let starts = 0;
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: paths.home,
|
||||
sessionRoot: paths.sessionRoot,
|
||||
startAdapter(options) {
|
||||
starts += 1;
|
||||
if (starts > 1) throw new Error("replacement failed to start");
|
||||
return adapters.startAdapter(options);
|
||||
},
|
||||
maxRecoveryAttempts: 1,
|
||||
recoveryDelayForAttempt: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
const runtime = await registry.start();
|
||||
|
||||
await assert.rejects(
|
||||
registry.restart(runtime.id),
|
||||
/Pi did not restart after the recovery attempts were exhausted/,
|
||||
);
|
||||
assert.equal(
|
||||
registry.getWorkspace().directories[0].runtimes[0].state,
|
||||
"failed",
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("child exit clears extension dialogs that recovery cannot answer", async () => {
|
||||
const paths = await fixture();
|
||||
const adapters = adapterFactory();
|
||||
|
||||
Reference in New Issue
Block a user