feat(quick-pane): promote active runtime

This commit is contained in:
2026-08-28 12:07:50 +02:00
parent 0a318f5ab6
commit b44b1be4b7
19 changed files with 1298 additions and 126 deletions
+248 -7
View File
@@ -5,8 +5,11 @@ import {
readdir,
readFile,
realpath,
rename,
rm,
stat,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
@@ -74,6 +77,69 @@ async function persistSessionReference(sessionDir, sessionPath, worktreePath) {
);
}
export async function promoteQuickSessionDirectory(
quickDir,
sessionDir,
{ renameFile = rename, makeSymlink = symlink } = {},
) {
const stagingDir = `${quickDir}.promoting-${randomUUID()}`;
const moved = [];
let quickStaged = false;
let quickLinked = false;
async function rollback() {
if (quickLinked) await unlink(quickDir).catch(() => {});
for (const entry of moved.reverse())
await renameFile(path.join(sessionDir, entry), path.join(stagingDir, entry));
if (quickStaged) await renameFile(stagingDir, quickDir);
}
try {
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
await renameFile(quickDir, stagingDir);
quickStaged = true;
await makeSymlink(sessionDir, quickDir, "dir");
quickLinked = true;
for (const entry of await readdir(stagingDir)) {
const destination = path.join(sessionDir, entry);
try {
await stat(destination);
throw new Error("quick session conflicts with an existing session file");
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
await renameFile(path.join(stagingDir, entry), destination);
moved.push(entry);
}
} catch (error) {
try {
await rollback();
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
"could not promote quick session directory; staging retained",
);
}
throw error;
}
return {
async rollback() {
try {
await rollback();
} catch (error) {
throw new Error(
`could not roll back quick session promotion; staging retained: ${error.message}`,
);
}
},
async finalize() {
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
},
};
}
function sessionPreview(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
@@ -245,6 +311,7 @@ export function createAgentRegistry({
homeWorktree,
sessionRoot,
startAdapter = startPiRpcAdapter,
workspaceStoreFactory = createWorkspaceStore,
eventLimit = DEFAULT_EVENT_LIMIT,
maxRecoveryAttempts,
recoveryDelayForAttempt,
@@ -257,9 +324,11 @@ export function createAgentRegistry({
throw new TypeError("sessionRoot must be an absolute path");
if (typeof startAdapter !== "function")
throw new TypeError("startAdapter must be a function");
if (typeof workspaceStoreFactory !== "function")
throw new TypeError("workspaceStoreFactory must be a function");
const bridgeInstanceId = randomUUID();
const workspaceStore = createWorkspaceStore(sessionRoot);
const workspaceStore = workspaceStoreFactory(sessionRoot);
const runtimesByRuntimeId = new Map();
const runtimesByAgentId = new Map();
const runtimesByWorktreePath = new Map();
@@ -272,6 +341,7 @@ export function createAgentRegistry({
const workspaceEvents = [];
let workspaceSequence = 0;
let manifestIssue;
let manifestWriteQueue = Promise.resolve();
let canonicalHomeWorktree;
let lastSuccessfulModelCatalog;
let stopping = false;
@@ -318,9 +388,15 @@ export function createAgentRegistry({
};
}
async function persistWorkspace() {
function persistWorkspace() {
const operation = manifestWriteQueue
.catch(() => {})
.then(async () => {
await workspaceStore.save(manifest());
manifestIssue = undefined;
});
manifestWriteQueue = operation.catch(() => {});
return operation;
}
function addWorktreeRuntime(runtime) {
@@ -430,10 +506,23 @@ export function createAgentRegistry({
return resolvedSessionPath;
}
async function projectQuickSessionPath(quickDir, sessionDir, sessionPath) {
const activePath = await ownedSessionPath(quickDir, sessionPath);
const relativePath = path.relative(await realpath(quickDir), activePath);
return ownedSessionPath(sessionDir, path.join(sessionDir, relativePath), {
allowMissing: true,
});
}
async function updateRuntimeIdentity(
runtime,
stateResponse,
{ expectedSessionPath, adapter = runtime.adapter } = {},
{
expectedSessionPath,
adapter = runtime.adapter,
persist = true,
indexSession = true,
} = {},
) {
if (
runtime.stopped ||
@@ -477,15 +566,17 @@ export function createAgentRegistry({
runtime.sessionName = state.sessionName;
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
if (resolved) {
if (indexSession) {
if (
runtime.sessionPath &&
runtime.sessionPath !== resolved &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
runtime.sessionPath = resolved;
runtimesBySessionPath.set(resolved, runtime);
releaseSessionReservation(resolved, runtime);
}
runtime.sessionPath = resolved;
if (runtime.legacyDefault)
await persistSessionReference(
runtime.sessionDir,
@@ -493,7 +584,16 @@ export function createAgentRegistry({
runtime.worktreePath,
);
}
await persistWorkspace();
if (persist) await persistWorkspace();
}
async function restoreSessionReference(sessionDir, previous) {
const referencePath = path.join(sessionDir, SESSION_REFERENCE_FILE);
if (previous === undefined) {
await rm(referencePath, { force: true });
return;
}
await writeFile(referencePath, previous, { encoding: "utf8", mode: 0o600 });
}
function publishAgent(runtime, type, data) {
@@ -587,6 +687,8 @@ export function createAgentRegistry({
adapter: undefined,
supervisor: undefined,
stopped: false,
promoting: false,
promotionPromise: undefined,
recoveryPromise: undefined,
};
}
@@ -1000,6 +1102,8 @@ export function createAgentRegistry({
function closeRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (runtime.promoting)
return runtime.promotionPromise.then(() => closeRuntime(runtimeId));
if (runtime.state === "closing" || runtime.state === "stopped")
return Promise.reject(new Error("session runtime is closing"));
const wasWorkingAtRequest =
@@ -1018,6 +1122,136 @@ export function createAgentRegistry({
return result;
}
function promoteQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (!runtime.ephemeral) return Promise.resolve(publicRuntime(runtime));
if (runtime.state === "closing" || runtime.state === "stopped")
return Promise.reject(new Error("session runtime is closing"));
if (runtime.promotionPromise) return runtime.promotionPromise;
runtime.promoting = true;
const promotion = runtime.operationQueue.then(async () => {
if (!runtime.ephemeral) return publicRuntime(runtime);
if (runtime.state === "closing" || runtime.state === "stopped")
throw new Error("session runtime is closing");
const adapter = runtime.adapter;
if (!adapter) throw new Error("Pi is not currently running");
const quickDir = runtime.sessionDir;
const sessionDir = sessionDirectoryFor(sessionRoot, runtime.worktreePath);
const previous = {
sessionDir: runtime.sessionDir,
sessionPath: runtime.sessionPath,
sessionId: runtime.sessionId,
sessionName: runtime.sessionName,
sessionLease: runtime.sessionPath
? runtimesBySessionPath.get(runtime.sessionPath) === runtime
: false,
managed: managedWorktrees.has(runtime.worktreePath),
};
let directoryPromotion;
let previousReference;
let reservedSessionPath;
let manifestStateChanged = false;
try {
await mkdir(sessionDir, {
recursive: true,
mode: SESSION_DIRECTORY_MODE,
});
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
if (runtime.sessionPath) {
reservedSessionPath = await projectQuickSessionPath(
quickDir,
sessionDir,
runtime.sessionPath,
);
reserveSessionPath(reservedSessionPath, runtime);
}
directoryPromotion = await promoteQuickSessionDirectory(
quickDir,
sessionDir,
);
runtime.sessionDir = sessionDir;
if (reservedSessionPath) runtime.sessionPath = reservedSessionPath;
const state = await adapter.send({ type: "get_state" });
await updateRuntimeIdentity(runtime, state, {
adapter,
persist: false,
indexSession: false,
});
// A changed identity still needs a lease through manifest commit.
if (runtime.sessionPath && runtime.sessionPath !== reservedSessionPath)
reserveSessionPath(runtime.sessionPath, runtime);
if (
previous.sessionPath &&
previous.sessionPath !== runtime.sessionPath &&
runtimesBySessionPath.get(previous.sessionPath) === runtime
)
runtimesBySessionPath.delete(previous.sessionPath);
runtime.ephemeral = false;
managedWorktrees.add(runtime.worktreePath);
manifestStateChanged = true;
try {
previousReference = await readFile(
path.join(sessionDir, SESSION_REFERENCE_FILE),
"utf8",
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
await persistSessionReference(
sessionDir,
runtime.sessionPath,
runtime.worktreePath,
);
await persistWorkspace();
if (runtime.sessionPath)
runtimesBySessionPath.set(runtime.sessionPath, runtime);
if (reservedSessionPath)
releaseSessionReservation(reservedSessionPath, runtime);
if (runtime.sessionPath !== reservedSessionPath)
releaseSessionReservation(runtime.sessionPath, runtime);
await directoryPromotion.finalize();
publishWorkspace("runtime_promoted", runtime, {
runtime: publicRuntime(runtime),
});
return publicRuntime(runtime);
} catch (error) {
if (directoryPromotion)
await restoreSessionReference(sessionDir, previousReference).catch(
() => {},
);
if (reservedSessionPath)
releaseSessionReservation(reservedSessionPath, runtime);
if (runtime.sessionPath !== reservedSessionPath)
releaseSessionReservation(runtime.sessionPath, runtime);
if (
runtime.sessionPath &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
runtime.sessionDir = previous.sessionDir;
runtime.sessionPath = previous.sessionPath;
runtime.sessionId = previous.sessionId;
runtime.sessionName = previous.sessionName;
runtime.ephemeral = true;
if (previous.sessionLease && previous.sessionPath)
runtimesBySessionPath.set(previous.sessionPath, runtime);
if (previous.managed) managedWorktrees.add(runtime.worktreePath);
else managedWorktrees.delete(runtime.worktreePath);
if (manifestStateChanged) await persistWorkspace().catch(() => {});
if (directoryPromotion) await directoryPromotion.rollback();
throw error;
}
});
const tracked = promotion.finally(() => {
runtime.promoting = false;
runtime.promotionPromise = undefined;
});
runtime.operationQueue = tracked.catch(() => {});
runtime.promotionPromise = tracked;
return tracked;
}
async function refreshAfterLegacySessionChange(
runtime,
response,
@@ -1208,13 +1442,19 @@ export function createAgentRegistry({
async createQuickRuntime(worktreePath) {
return openRuntime({ worktreePath, ephemeral: true });
},
promoteQuickRuntime,
async openSessionRuntime(worktreePath, sessionPath) {
return openRuntime({ worktreePath, sessionPath });
},
closeSessionRuntime: closeRuntime,
async closeQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (!runtime.ephemeral) throw new Error("runtime is not a quick runtime");
if (runtime.promoting) {
await runtime.promotionPromise;
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
}
if (!runtime.ephemeral)
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
return closeRuntime(runtimeId);
},
async getModelCatalog() {
@@ -1478,7 +1718,8 @@ export function createAgentRegistry({
},
async route(agentId, operation, payload = {}) {
const runtime = getAgent(agentId);
if (operation === "abort") return runtime.adapter.send({ type: "abort" });
if (operation === "abort" && !runtime.promoting)
return runtime.adapter.send({ type: "abort" });
return enqueue(runtime, async () => {
if (operation === "switch_session") {
if (runtime.state === "streaming")
+4
View File
@@ -54,6 +54,10 @@ export async function startBridgeService({
request.payload.worktreePath,
),
};
case "promote_quick_runtime":
return {
runtime: await registry.promoteQuickRuntime(request.payload.runtimeId),
};
case "open_session_runtime":
return {
runtime: await registry.openSessionRuntime(
+1
View File
@@ -14,6 +14,7 @@ const requestOperations = new Map([
["get_model_catalog", { agent: false, payload: "none" }],
["create_session_runtime", { agent: false, payload: "worktree" }],
["create_quick_runtime", { agent: false, payload: "worktree" }],
["promote_quick_runtime", { agent: false, payload: "runtime" }],
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
["close_session_runtime", { agent: false, payload: "runtime" }],
["close_quick_runtime", { agent: false, payload: "runtime" }],
+1
View File
@@ -125,6 +125,7 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
{ op: "get_workspace_summary" },
{ op: "create_session_runtime", payload: { worktreePath } },
{ op: "create_quick_runtime", payload: { worktreePath } },
{ op: "promote_quick_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
+28 -2
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("Quick Pane polls snapshots, renders answers, and gates manual transcript handoff", async () => {
test("Quick Pane polls snapshots, renders answers, and promotes runtime without a handoff", async () => {
const source = await readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
@@ -13,7 +13,12 @@ test("Quick Pane polls snapshots, renders answers, and gates manual transcript h
assert.match(source, /className="quick-pane-notice escalation"/);
assert.match(source, /className="quick-pane-assistant-footer"/);
assert.match(source, /Continue in full session/);
assert.match(source, /Quick Pane transcript:/);
assert.match(
source,
/await invoke<\{ runtime: Runtime \}>\("promote_quick_runtime", \{\s+runtimeId: current\.runtimeId,/,
);
assert.doesNotMatch(source, /Quick Pane transcript:/);
assert.doesNotMatch(source, /create_session_runtime/);
assert.doesNotMatch(source, /maximumSeconds|Quick time budget/);
});
@@ -166,6 +171,27 @@ test("Quick Pane dismiss and mutually exclusive window visibility stay native-se
assert.ok(quickClearMain >= 0 && quickClearMain < showQuick);
});
test("native promotion owns visibility and Main consumes pending runtime after startup refresh", async () => {
const [nativeSource, workspaceSource] = await Promise.all([
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
readFile(new URL("../ui/src/workspace/useWorkspace.ts", import.meta.url), "utf8"),
]);
const promotion = nativeSource.slice(
nativeSource.indexOf("fn show_main_for_promoted_runtime"),
nativeSource.indexOf("fn toggle_quick_pane"),
);
assert.match(promotion, /get_or_create_main_with_visibility\(app, false\)/);
assert.ok(promotion.indexOf("quick_pane\n .hide()") < promotion.indexOf("window\n .show()"));
assert.ok(promotion.indexOf('visibility.hidden("quick-pane")') < promotion.indexOf("window\n .show()"));
assert.match(promotion, /\*pending = Some\(runtime_id\)/);
assert.match(nativeSource, /fn get_pending_promoted_runtime/);
assert.match(nativeSource, /fn consume_pending_promoted_runtime/);
assert.match(workspaceSource, /await promotedListenerReady/);
assert.match(workspaceSource, /await refresh\(loaded\)/);
assert.match(workspaceSource, /get_pending_promoted_runtime/);
assert.match(workspaceSource, /consume_pending_promoted_runtime/);
});
test("Quick Pane routes extension responses through existing bridge command", async () => {
const source = await readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
+473
View File
@@ -0,0 +1,473 @@
import assert from "node:assert/strict";
import {
lstat,
mkdir,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
createAgentRegistry,
promoteQuickSessionDirectory,
sessionDirectoryPath,
} from "../src/bridge/agent-registry.js";
import { createWorkspaceStore } from "../src/bridge/workspace-store.js";
function adapters() {
const calls = [];
return {
calls,
startAdapter(options) {
const adapter = {
sent: [],
stopped: false,
state: {},
async send(command) {
this.sent.push(command);
return {
type: "response",
command: command.type,
success: true,
data: command.type === "get_state" ? this.state : {},
};
},
respondToExtension() {},
async stop() {
this.stopped = true;
},
};
calls.push({ options, adapter });
return adapter;
},
};
}
test("promotes an active quick runtime in place and makes stale quick cleanup harmless", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const quickAdapter = fixture.calls.at(-1).adapter;
await registry.route(quick.agentId, "prompt", { message: "keep working" });
const events = [];
const subscription = registry.subscribeWorkspace(0, (event) =>
events.push(event),
);
const [promoted, repeated] = await Promise.all([
registry.promoteQuickRuntime(quick.runtimeId),
registry.promoteQuickRuntime(quick.runtimeId),
]);
assert.equal(promoted.runtimeId, quick.runtimeId);
assert.equal(repeated.agentId, quick.agentId);
assert.equal(fixture.calls.at(-1).adapter, quickAdapter);
assert.equal(quickAdapter.stopped, false);
assert.equal(promoted.sessionPath, undefined);
assert.ok(events.some((event) => event.type === "runtime_promoted"));
assert.equal(
registry
.getWorkspace()
.directories[0].runtimes.some(
(runtime) => runtime.runtimeId === quick.runtimeId,
),
true,
);
assert.match(
await readFile(join(root, "sessions", "bridge-workspace-v2.json"), "utf8"),
new RegExp(quick.runtimeId),
);
assert.match(
await readFile(
join(
sessionDirectoryPath(join(root, "sessions"), home),
"bridge-agent.json",
),
"utf8",
),
/"worktreePath"/,
);
await registry.closeQuickRuntime(quick.runtimeId);
assert.equal(quickAdapter.stopped, false);
await registry.route(quick.agentId, "abort");
subscription.unsubscribe();
await registry.stop();
});
test("promotion refreshes active JSONL identity without replacing runtime state", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-identity-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, '{"type":"session"}\n');
call.adapter.state = { sessionFile: quickPath, sessionId: "session-active" };
call.options.onEvent({
type: "queue",
data: { event: { pendingMessageCount: 2 } },
});
call.options.onEvent({
type: "extension_ui_request",
data: { event: { id: "extension-1", method: "confirm" } },
});
await registry.route(quick.agentId, "set_model", {
provider: "test",
modelId: "model-1",
});
await registry.route(quick.agentId, "set_thinking_level", { level: "high" });
const promoted = await registry.promoteQuickRuntime(quick.runtimeId);
const snapshot = await registry.getSessionRuntimeSnapshot(quick.runtimeId);
assert.equal(promoted.runtimeId, quick.runtimeId);
assert.equal(promoted.agentId, quick.agentId);
assert.equal(promoted.sessionId, "session-active");
assert.match(promoted.sessionPath, /active\.jsonl$/);
assert.equal(snapshot.runtime.queueCount, 2);
assert.equal(snapshot.extensions[0].id, "extension-1");
assert.deepEqual(
call.adapter.sent.filter((command) =>
["set_model", "set_thinking_level"].includes(command.type),
),
[
{ type: "set_model", provider: "test", modelId: "model-1" },
{ type: "set_thinking_level", level: "high" },
],
);
assert.equal(fixture.calls.at(-1).adapter, call.adapter);
await registry.stop();
});
test("session-directory migration rolls back move and symlink failures for retry", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-directory-rollback-"));
const quickDir = join(root, "quick");
const sessionDir = join(root, "session");
await mkdir(quickDir);
await writeFile(join(quickDir, "active.jsonl"), "history");
let renameCalls = 0;
await assert.rejects(
promoteQuickSessionDirectory(quickDir, sessionDir, {
renameFile: async (...args) => {
renameCalls += 1;
if (renameCalls === 2) throw new Error("move failed");
return rename(...args);
},
}),
);
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
await assert.rejects(
promoteQuickSessionDirectory(quickDir, sessionDir, {
makeSymlink: async () => {
throw new Error("symlink failed");
},
}),
);
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
const transaction = await promoteQuickSessionDirectory(quickDir, sessionDir);
await transaction.finalize();
assert.equal(
await readFile(join(sessionDir, "active.jsonl"), "utf8"),
"history",
);
});
test("promotion queues abort and close until identity persistence completes", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-race-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, "history");
call.adapter.state = { sessionFile: quickPath, sessionId: "session-race" };
const send = call.adapter.send.bind(call.adapter);
let releaseIdentity;
let blockIdentity = true;
call.adapter.send = async (command) => {
if (command.type !== "get_state" || !blockIdentity) return send(command);
return new Promise((resolve) => {
releaseIdentity = () => {
blockIdentity = false;
resolve({
type: "response",
command: "get_state",
success: true,
data: call.adapter.state,
});
};
});
};
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!releaseIdentity) await new Promise((resolve) => setImmediate(resolve));
const abort = registry.route(quick.agentId, "abort");
const close = registry.closeSessionRuntime(quick.runtimeId);
assert.notEqual(call.adapter.sent.at(-1).type, "abort");
releaseIdentity();
await Promise.all([promotion, abort, close]);
assert.ok(call.adapter.sent.some((command) => command.type === "abort"));
assert.equal(call.adapter.stopped, true);
await registry.stop();
});
test("promotion reserves moved JSONL before blocking get_state and releases on rollback", async () => {
const root = await mkdtemp(
join(tmpdir(), "pi-quick-promote-get-state-lease-"),
);
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const fixture = adapters();
const startAdapter = (options) => {
const adapter = fixture.startAdapter(options);
if (options.sessionPath) adapter.state = { sessionFile: options.sessionPath };
return adapter;
};
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
const promotedPath = join(
sessionDirectoryPath(sessionRoot, home),
"active.jsonl",
);
await writeFile(quickPath, "history");
call.adapter.state = {
sessionFile: quickPath,
sessionId: "session-get-state-lease",
};
call.options.onEvent({ type: "agent_state", data: { state: "idle" } });
while (
!(await registry.getSessionRuntimeSnapshot(quick.runtimeId)).runtime
.sessionPath
)
await new Promise((resolve) => setImmediate(resolve));
const send = call.adapter.send.bind(call.adapter);
let rejectState;
call.adapter.send = async (command) => {
if (command.type !== "get_state") return send(command);
return new Promise((_, reject) => {
rejectState = () => reject(new Error("get_state failed"));
});
};
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!rejectState) await new Promise((resolve) => setImmediate(resolve));
assert.equal((await lstat(call.options.sessionDir)).isSymbolicLink(), true);
assert.equal(await readFile(promotedPath, "utf8"), "history");
const childrenBeforeOpen = fixture.calls.length;
await assert.rejects(
registry.openSessionRuntime(home, promotedPath),
/already open/,
);
assert.equal(fixture.calls.length, childrenBeforeOpen);
rejectState();
await assert.rejects(promotion, /get_state failed/);
await writeFile(promotedPath, "history");
await registry.openSessionRuntime(home, promotedPath);
assert.equal(fixture.calls.length, childrenBeforeOpen + 1);
await registry.stop();
});
test("promotion reserves its canonical JSONL before reference and manifest I/O", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-lease-"));
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const fixture = adapters();
let quick;
let releasePromotionSave;
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: fixture.startAdapter,
workspaceStoreFactory(rootPath) {
const store = createWorkspaceStore(rootPath);
return {
...store,
save(next) {
if (
!next.runtimes.some((runtime) => runtime.runtimeId === quick?.runtimeId)
)
return store.save(next);
return new Promise((resolve, reject) => {
releasePromotionSave = () => store.save(next).then(resolve, reject);
});
},
};
},
});
await registry.start();
quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, "history");
call.adapter.state = { sessionFile: quickPath, sessionId: "session-lease" };
const promotedPath = join(
sessionDirectoryPath(sessionRoot, home),
"active.jsonl",
);
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!releasePromotionSave)
await new Promise((resolve) => setImmediate(resolve));
await assert.rejects(
registry.openSessionRuntime(home, promotedPath),
/already open/,
);
releasePromotionSave();
await promotion;
await registry.stop();
});
test("concurrent manifest persistence retains promoted runtime for restart", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-persist-"));
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const fixture = adapters();
let releasePromotionSave;
let holdPromotionSave = true;
let quick;
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: fixture.startAdapter,
workspaceStoreFactory(rootPath) {
const store = createWorkspaceStore(rootPath);
return {
...store,
save(next) {
if (
!holdPromotionSave ||
!next.runtimes.some((runtime) => runtime.runtimeId === quick?.runtimeId)
)
return store.save(next);
holdPromotionSave = false;
return new Promise((resolve, reject) => {
releasePromotionSave = () => store.save(next).then(resolve, reject);
});
},
};
},
});
await registry.start();
quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, "history");
call.adapter.state = { sessionFile: quickPath, sessionId: "session-persist" };
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!releasePromotionSave)
await new Promise((resolve) => setImmediate(resolve));
const concurrentOpen = registry.createSessionRuntime(home);
releasePromotionSave();
await Promise.all([promotion, concurrentOpen]);
const manifest = JSON.parse(
await readFile(join(sessionRoot, "bridge-workspace-v2.json"), "utf8"),
);
assert.ok(
manifest.runtimes.some((runtime) => runtime.runtimeId === quick.runtimeId),
);
await registry.stop();
const restored = adapters();
const originalStart = restored.startAdapter;
restored.startAdapter = (options) => {
const adapter = originalStart(options);
if (options.sessionPath) adapter.state = { sessionFile: options.sessionPath };
return adapter;
};
const restarted = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: restored.startAdapter,
});
await restarted.start();
assert.ok(
restarted
.getWorkspace()
.directories.flatMap((directory) => directory.runtimes)
.some(
(runtime) =>
runtime.runtimeId === quick.runtimeId && runtime.state !== "failed",
),
);
await restarted.stop();
});
test("promotion reference and manifest failures roll back quick runtime for retry", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-persist-rollback-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const sessionRoot = join(root, "sessions");
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const quickDir = fixture.calls.at(-1).options.sessionDir;
await writeFile(join(quickDir, "active.jsonl"), "history");
const sessionDir = sessionDirectoryPath(sessionRoot, home);
const referencePath = join(sessionDir, "bridge-agent.json");
await mkdir(referencePath);
await assert.rejects(registry.promoteQuickRuntime(quick.runtimeId));
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
assert.equal((await lstat(quickDir)).isDirectory(), true);
await rm(referencePath, { recursive: true });
const workspacePath = join(sessionRoot, "bridge-workspace-v2.json");
await rm(workspacePath);
await mkdir(workspacePath);
await assert.rejects(registry.promoteQuickRuntime(quick.runtimeId));
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
assert.equal((await lstat(quickDir)).isDirectory(), true);
await rm(workspacePath, { recursive: true });
assert.equal(
(await registry.promoteQuickRuntime(quick.runtimeId)).runtimeId,
quick.runtimeId,
);
await registry.stop();
});
+11
View File
@@ -228,6 +228,17 @@ pub async fn create_quick_runtime(
)
.await
}
pub async fn promote_quick_runtime(
socket_path: &str,
runtime_id: &str,
) -> Result<RuntimeResult, String> {
typed_request(
socket_path,
"promote_quick_runtime",
Some(json!({ "runtimeId": runtime_id })),
)
.await
}
pub async fn open_session_runtime(
socket_path: &str,
worktree_path: &str,
+101 -1
View File
@@ -41,6 +41,7 @@ impl ManagedWindowVisibility {
}
// One mutex serializes cross-window transitions, avoiding nested visibility-lock ordering.
struct WindowVisibility(Mutex<ManagedWindowVisibility>);
struct PendingPromotedRuntime(Mutex<Option<String>>);
struct WorkspaceSubscription {
generation: Arc<bridge::SubscriptionGeneration>,
task: Mutex<Option<JoinHandle<()>>>,
@@ -96,7 +97,10 @@ fn should_hide_close_request(label: &str) -> bool {
label == "main" || label == "quick-pane"
}
fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
fn get_or_create_main_with_visibility(
app: &AppHandle,
visible_when_created: bool,
) -> Result<(WebviewWindow, bool), String> {
if let Some(window) = app.get_webview_window("main") {
return Ok((window, false));
}
@@ -109,11 +113,16 @@ fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String>
.ok_or_else(|| "Main window configuration is missing".to_owned())?;
let window = WebviewWindowBuilder::from_config(app, config)
.map_err(|error| error.to_string())?
.visible(visible_when_created)
.build()
.map_err(|error| error.to_string())?;
Ok((window, true))
}
fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
get_or_create_main_with_visibility(app, true)
}
fn apply_main_window_action(app: &AppHandle, args: &[String]) {
let (window, created) = match get_or_create_main(app) {
Ok(result) => result,
@@ -174,6 +183,44 @@ fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), St
Ok((window, true))
}
fn show_main_for_promoted_runtime(app: &AppHandle, runtime_id: String) -> Result<(), String> {
// A recreated Main starts hidden, so Quick is never visible with it.
let (window, created) = get_or_create_main_with_visibility(app, false)?;
let state = app
.try_state::<WindowVisibility>()
.ok_or_else(|| "Window visibility state is unavailable".to_owned())?;
let pending = app
.try_state::<PendingPromotedRuntime>()
.ok_or_else(|| "Promoted runtime state is unavailable".to_owned())?;
let mut visibility = state
.0
.lock()
.map_err(|_| "Could not update window visibility".to_owned())?;
let mut pending = pending
.0
.lock()
.map_err(|_| "Could not queue promoted runtime".to_owned())?;
if created {
visibility.main = false;
}
if let Some(quick_pane) = app.get_webview_window("quick-pane") {
quick_pane
.hide()
.map_err(|error| format!("Could not hide Quick Pane before promotion: {error}"))?;
}
visibility.hidden("quick-pane");
// Retain this on show failure; a later Main startup can consume it.
*pending = Some(runtime_id);
visibility.main = false;
window
.show()
.map_err(|error| format!("Could not show Main after hiding Quick Pane: {error}"))?;
visibility.main_shown();
let _ = window.unminimize();
let _ = window.set_focus();
Ok(())
}
fn toggle_quick_pane(app: &AppHandle) {
let (window, created) = match get_or_create_quick_pane(app) {
Ok(result) => result,
@@ -333,6 +380,45 @@ async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeRe
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn promote_quick_runtime(
app: AppHandle,
runtime_id: String,
) -> Result<bridge::RuntimeResult, String> {
let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?;
show_main_for_promoted_runtime(&app, result.runtime.runtime_id.clone())?;
let _ = app.emit("workspace-promoted-runtime", json!({ "runtimeId": result.runtime.runtime_id }));
Ok(result)
}
#[tauri::command]
fn get_pending_promoted_runtime(
pending: State<'_, PendingPromotedRuntime>,
) -> Result<Option<String>, String> {
pending
.0
.lock()
.map(|pending| pending.clone())
.map_err(|_| "Could not read promoted runtime".to_owned())
}
#[tauri::command]
fn consume_pending_promoted_runtime(
runtime_id: String,
pending: State<'_, PendingPromotedRuntime>,
) -> Result<bool, String> {
let mut pending = pending
.0
.lock()
.map_err(|_| "Could not consume promoted runtime".to_owned())?;
if pending.as_deref() == Some(runtime_id.as_str()) {
*pending = None;
Ok(true)
} else {
Ok(false)
}
}
#[tauri::command]
async fn open_session_runtime(
worktree_path: String,
@@ -582,6 +668,7 @@ pub fn run() {
main: true,
quick_pane: false,
})))
.manage(PendingPromotedRuntime(Mutex::new(None)))
.manage(WorkspaceSubscription {
generation: Arc::new(bridge::SubscriptionGeneration::default()),
task: Mutex::new(None),
@@ -628,6 +715,9 @@ pub fn run() {
dismiss_quick_pane,
create_session_runtime,
create_quick_runtime,
promote_quick_runtime,
get_pending_promoted_runtime,
consume_pending_promoted_runtime,
open_session_runtime,
close_session_runtime,
close_quick_runtime,
@@ -742,6 +832,16 @@ mod tests {
);
}
#[test]
fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() {
let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned())));
let mut value = pending.0.lock().unwrap();
assert_ne!(value.as_deref(), Some("runtime-b"));
assert_eq!(value.as_deref(), Some("runtime-a"));
*value = None;
assert_eq!(value.as_deref(), None);
}
#[test]
fn dismissed_quick_pane_reopens_with_one_toggle() {
let mut visibility = ManagedWindowVisibility {
+1 -1
View File
@@ -337,7 +337,7 @@ export function ConversationWorkspace({
pending={isModelUpdating || locked}
/>
<ThinkingSelector
model={selectedThinkingModel}
model={selectedThinkingModel ?? runtime.state.model}
value={runtime.state.thinkingLevel ?? "medium"}
disabled={isModelUpdating || locked}
onChange={(level) => void onThinking(level)}
+12 -2
View File
@@ -6,7 +6,15 @@ it("uses selected model thinking capabilities and reconciles unavailable values"
const onChange = vi.fn();
render(
<ThinkingSelector
model={{ thinkingLevelMap: { off: "off", high: "high" } }}
model={{
reasoning: true,
thinkingLevelMap: {
minimal: null,
low: null,
medium: null,
high: "high",
},
}}
value="medium"
onChange={onChange}
/>,
@@ -15,7 +23,9 @@ it("uses selected model thinking capabilities and reconciles unavailable values"
const selector = screen.getByRole("combobox", { name: "Thinking" });
expect(selector).toHaveValue("off");
expect(
within(selector).getAllByRole("option").map((option) => option.textContent),
within(selector)
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["Off", "High"]);
fireEvent.change(selector, { target: { value: "high" } });
expect(onChange).toHaveBeenCalledWith("high");
+29
View File
@@ -178,6 +178,35 @@ describe("multi-session navigation", () => {
await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open");
});
it("uses omitted-reasoning state model metadata when catalog lacks current model", () => {
const runtime = runtimeView(summary("thinking"));
runtime.state = {
model: {
provider: "provider",
id: "current",
thinkingLevelMap: { high: null, xhigh: "xhigh" },
},
};
render(
<ConversationWorkspace
runtime={runtime}
onDraft={vi.fn()}
onSubmit={vi.fn()}
onAbort={vi.fn()}
onScroll={vi.fn()}
onModel={vi.fn()}
onThinking={vi.fn()}
onCommand={vi.fn()}
/>,
);
expect(
Array.from(
screen
.getByRole("combobox", { name: "Thinking" })
.querySelectorAll("option"),
).map((option) => option.value),
).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
});
it("packs session status between model controls and the composer", () => {
const runtime = runtimeView(summary("status", "streaming"));
runtime.summary.queueCount = 2;
+46 -15
View File
@@ -6,29 +6,60 @@ import {
} from "./model-catalog";
describe("thinking model catalog metadata", () => {
it("uses catalog thinking levels and reconciles removed selections", () => {
const model = {
provider: "provider",
id: "reasoning",
thinkingLevelMap: { off: "off", low: "low", high: "high" },
};
expect(thinkingLevelOptions(model)).toEqual([
it("matches Pi reasoning defaults, null exclusions, and explicit max", () => {
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
expect(thinkingLevelOptions({ reasoning: false })).toEqual([
{ value: "off", label: "Off" },
]);
expect(thinkingLevelOptions({})).toEqual([
{ value: "off", label: "Off" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]);
expect(reconcileThinkingLevel(model, "high")).toBe("high");
expect(reconcileThinkingLevel(model, "medium")).toBe("off");
const mapped = {
thinkingLevelMap: {
off: null,
low: null,
xhigh: "xhigh",
max: "max",
},
};
expect(thinkingLevelOptions(mapped)).toEqual([
{ value: "minimal", label: "Minimal" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "XHigh" },
{ value: "max", label: "Max" },
]);
expect(reconcileThinkingLevel(mapped, "off")).toBe("minimal");
expect(reconcileThinkingLevel(mapped, "xhigh")).toBe("xhigh");
const unavailable = {
thinkingLevelMap: {
off: null,
minimal: null,
low: null,
medium: null,
high: null,
xhigh: null,
},
};
expect(reconcileThinkingLevel(unavailable, "high")).toBe("off");
});
it("keeps unknown and dynamically configured local models at Off", () => {
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
const [dynamic] = catalogModels([], [
{ provider: "localai", modelId: "local/unknown" },
]);
expect(dynamic.thinkingLevelMap).toEqual({ off: "off" });
it("treats dynamically configured models with omitted reasoning as capable", () => {
const [dynamic] = catalogModels(
[],
[{ provider: "localai", modelId: "local/unknown" }],
);
expect(dynamic.thinkingLevelMap).toBeUndefined();
expect(thinkingLevelOptions(dynamic)).toEqual([
{ value: "off", label: "Off" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]);
});
});
+31 -13
View File
@@ -3,8 +3,21 @@ import type { Model } from "./workspace/types";
export type ModelIdentity = { provider: string; modelId: string };
export type ThinkingLevelOption = { value: string; label: string };
const DEFAULT_THINKING_LEVELS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;
const OFF_THINKING_LEVEL: ThinkingLevelOption = { value: "off", label: "Off" };
function thinkingLevelLabel(level: string) {
if (level === "xhigh") return "XHigh";
return level[0].toUpperCase() + level.slice(1);
}
export function modelIdentity(model: Model): ModelIdentity | undefined {
if (!model.provider || !model.id) return undefined;
return { provider: model.provider, modelId: model.id };
@@ -49,22 +62,29 @@ export function reconcileModelIdentity(
}
export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] {
const map = model?.thinkingLevelMap;
if (!map || typeof map !== "object") return [OFF_THINKING_LEVEL];
const options = Object.keys(map)
.filter((level) => level.trim() && typeof map[level] === "string")
.map((level) => ({
value: level,
label: level === "off" ? "Off" : level[0].toUpperCase() + level.slice(1),
}));
return options.length ? options : [OFF_THINKING_LEVEL];
if (!model || model.reasoning === false) return [OFF_THINKING_LEVEL];
const map = model.thinkingLevelMap;
const levels = [
...DEFAULT_THINKING_LEVELS.filter((level) => {
if (level === "xhigh") return map?.xhigh !== undefined && map.xhigh !== null;
return map?.[level] !== null;
}),
...(map?.max == null ? [] : ["max"]),
];
return levels.length
? levels.map((value) => ({ value, label: thinkingLevelLabel(value) }))
: [OFF_THINKING_LEVEL];
}
export function reconcileThinkingLevel(model: Model | undefined, level: string) {
export function reconcileThinkingLevel(
model: Model | undefined,
level: string,
) {
const options = thinkingLevelOptions(model);
return options.some((option) => option.value === level)
? level
: options.find((option) => option.value === "off")?.value ?? options[0].value;
: (options.find((option) => option.value === "off")?.value ??
options[0].value);
}
export function catalogModels(
@@ -85,8 +105,6 @@ export function catalogModels(
available.unshift({
provider: identity.provider,
id: identity.modelId,
// Configured/local models are not catalog-confirmed. Do not imply thinking support.
thinkingLevelMap: { off: "off" },
});
keys.add(key);
}
+29 -35
View File
@@ -389,7 +389,8 @@ describe("QuickPane", () => {
provider: "provider",
id: "quick/model",
name: "Quick Model",
thinkingLevelMap: { off: "off", high: "high" },
reasoning: true,
thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high" },
},
];
bridgeMock([thinkingModels], () =>
@@ -663,23 +664,13 @@ describe("QuickPane", () => {
expect(response.scrollTop).toBe(100);
});
it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
it("promotes completed quick runtime without copying a handoff", async () => {
bridgeMock([defaultModels], () =>
snapshotWithTranscript([
{ role: "user", content: "Inspect" },
{ role: "assistant", content: "Completed answer" },
]),
);
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "create_session_runtime")
return Promise.resolve({
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
});
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
@@ -690,19 +681,26 @@ describe("QuickPane", () => {
expect(
continueButton.closest(".quick-pane-assistant-footer"),
).not.toBeNull();
const submittedPromptCount = mocks.invoke.mock.calls.filter(
([command]) => command === "submit_prompt",
).length;
await userEvent.click(continueButton);
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("promote_quick_runtime", {
runtimeId: "runtime-1",
}),
);
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane");
expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_session_runtime",
expect.anything(),
);
await userEvent.click(continueButton);
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
agentId: "agent-full",
message:
"Inspect\n\nQuick Pane handoff: Continue this chat in a full session.\n\nQuick Pane transcript:\nUser: Inspect\n\nAssistant: Completed answer",
}),
);
expect(
mocks.invoke.mock.calls.filter(
([command]) => command === "submit_prompt",
),
).toHaveLength(submittedPromptCount);
});
it("keeps unlimited follow-ups on one runtime and renders snapshot messages in order", async () => {
@@ -988,10 +986,6 @@ describe("QuickPane", () => {
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "create_session_runtime")
return Promise.resolve({
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
});
if (command === "submit_prompt" && payload?.agentId === "agent-1")
submittedPrompts.push(String(payload.message));
return baseImplementation?.(command, payload);
@@ -1011,18 +1005,18 @@ describe("QuickPane", () => {
await screen.findByText("Second runtime failed."),
).toBeInTheDocument();
expect(screen.getByText("First answer")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", {
worktreePath: "/repo",
await userEvent.click(
within(screen.getByRole("status")).getByRole("button", {
name: "Continue",
}),
);
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
agentId: "agent-full",
message:
"Second\n\nQuick Pane handoff: Quick runtime failed. Continue in a full session if needed.\n\nQuick Pane transcript:\nUser: First\n\nAssistant: First answer\n\nUser: Second",
});
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("promote_quick_runtime", {
runtimeId: "runtime-1",
}),
);
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane");
});
it("maps the exact submitted payload to the entered multiline request despite delimiter collisions", async () => {
+23 -32
View File
@@ -213,6 +213,7 @@ export default function QuickPane() {
const [submissionStarting, setSubmissionStarting] = useState(false);
const [requestActive, setRequestActive] = useState(false);
const [chatReplacing, setChatReplacing] = useState(false);
const [promoting, setPromoting] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState<
@@ -229,7 +230,6 @@ export default function QuickPane() {
const [catalogNotice, setCatalogNotice] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [answer, setAnswer] = useState("");
const [responseComplete, setResponseComplete] = useState(false);
const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>();
@@ -634,7 +634,6 @@ export default function QuickPane() {
setRequestActive(false);
setSubmissionStarting(false);
setMessages([]);
setAnswer("");
setResponseComplete(false);
setEscalation(undefined);
setExtension(undefined);
@@ -894,8 +893,6 @@ export default function QuickPane() {
activeTool: snapshot.runtime.activeTool,
hasAssistantText: !!text,
});
if (promptSubmittedRuntimeRef.current === runtime.runtimeId)
setAnswer(result);
setExtension(snapshot.extensions[0]);
if (
complete &&
@@ -983,7 +980,6 @@ export default function QuickPane() {
};
promptSubmittedRuntimeRef.current = undefined;
completionHandledRef.current = false;
setAnswer("");
setResponseComplete(false);
setError(undefined);
setEscalation(undefined);
@@ -1050,9 +1046,7 @@ export default function QuickPane() {
}
}
const handoffRequest =
request.trim() || submittedRequestRef.current?.request || "";
const canContinue = !invalid && !!handoffRequest;
const canContinue = !invalid && !!runtime && !promoting;
const completedAssistantAnswer =
responseComplete &&
messages.some((message) => message.role === "assistant");
@@ -1069,35 +1063,22 @@ export default function QuickPane() {
responseComplete ||
submittedRequestRef.current !== undefined;
const hasTranscript = messages.length > 0 || runActive || responseComplete;
const transcriptHandoff = messages
.map(
(message) =>
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
)
.join("\n\n");
async function continueFull() {
if (invalid || !handoffRequest)
return setError(invalid ?? "Enter a request.");
const current = runtimeRef.current;
if (invalid || !current) return setError(invalid ?? "Quick runtime is unavailable.");
setPromoting(true);
try {
const result = await invoke<{ runtime: Runtime }>(
"create_session_runtime",
{
worktreePath: settings.workspace,
},
);
await invoke("set_model", {
agentId: result.runtime.agentId,
provider: settings.followUpProvider,
modelId: settings.followUpModel,
await invoke<{ runtime: Runtime }>("promote_quick_runtime", {
runtimeId: current.runtimeId,
});
await invoke("submit_prompt", {
agentId: result.runtime.agentId,
message: `${handoffRequest}\n\nQuick Pane handoff: ${escalation ?? "Continue this chat in a full session."}\n\nQuick Pane transcript:\n${transcriptHandoff || answer}`,
});
setNotice("Full session started with follow-up model.");
cleanupQueueRef.current.delete(current.runtimeId);
clearRuntimeIfCurrent(current.runtimeId);
await dismissPane();
} catch (reason) {
setError(`Could not start full session: ${String(reason)}`);
setError(`Could not continue in full session: ${String(reason)}`);
} finally {
setPromoting(false);
}
}
@@ -1187,6 +1168,16 @@ export default function QuickPane() {
/>
</div>
<div className="quick-pane-header-actions">
{runtime && (
<button
className="quick-pane-action"
type="button"
disabled={catalogOverlayActive || promoting}
onClick={continueFull}
>
{promoting ? "Continuing…" : "Continue"}
</button>
)}
<button
className="quick-pane-close"
type="button"
+140
View File
@@ -0,0 +1,140 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationWorkspace } from "./components/ConversationWorkspace";
import QuickPane from "./quick-pane/QuickPane";
import { runtimeView } from "./workspace/reducer";
import type { Model, RuntimeSnapshot, UiState } from "./workspace/types";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
listen: vi.fn(),
onFocusChanged: vi.fn(),
startDragging: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({
onFocusChanged: mocks.onFocusChanged,
startDragging: mocks.startDragging,
}),
}));
const settings: UiState = {
version: 1,
directoryOrder: [],
runtimeOrderByDirectory: {},
drafts: {},
scroll: {},
lastSeen: {},
quickPane: {
workspace: "/repo",
provider: "provider",
model: "model",
followUpProvider: "provider",
followUpModel: "model",
instructions: "Stay read-only.",
maximumToolCalls: 6,
},
};
function options(selector: HTMLSelectElement) {
return within(selector)
.getAllByRole("option")
.map((option) => option.getAttribute("value"));
}
function renderSurfaces(model: Model) {
const runtime = runtimeView({
runtimeId: "runtime",
worktreePath: "/repo",
state: "idle",
label: "Session",
attention: false,
queueCount: 0,
lastActivity: "now",
openedAt: "now",
});
runtime.models = [model];
runtime.state = { model, thinkingLevel: "high" };
const snapshot: RuntimeSnapshot = {
bridgeInstanceId: "bridge",
latestSeq: 1,
runtime: runtime.summary,
state: { data: runtime.state },
models: { data: { models: runtime.models } },
extensions: [],
};
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(settings);
if (command === "create_quick_runtime")
return Promise.resolve({
runtime: { agentId: "agent", runtimeId: "quick" },
});
if (command === "get_session_runtime_snapshot")
return Promise.resolve(snapshot);
return Promise.resolve(undefined);
});
render(
<>
<ConversationWorkspace
runtime={runtime}
onDraft={vi.fn()}
onSubmit={vi.fn()}
onAbort={vi.fn()}
onScroll={vi.fn()}
onModel={vi.fn().mockResolvedValue(true)}
onThinking={vi.fn().mockResolvedValue(true)}
onCommand={vi.fn()}
/>
<QuickPane />
</>,
);
}
describe("thinking selector parity", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listen.mockResolvedValue(vi.fn());
mocks.onFocusChanged.mockResolvedValue(vi.fn());
mocks.startDragging.mockResolvedValue(undefined);
});
it.each([
[
"omitted reasoning and map",
{},
["off", "minimal", "low", "medium", "high"],
],
[
"null exclusions",
{ thinkingLevelMap: { minimal: null, high: null } },
["off", "low", "medium"],
],
[
"explicit xhigh and max",
{ thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
["off", "minimal", "low", "medium", "high", "xhigh", "max"],
],
[
"non-reasoning",
{ reasoning: false, thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
["off"],
],
] as const)(
"matches Pi %s semantics in Conversation Workspace and Quick Pane",
async (_name, metadata, expected) => {
renderSurfaces({ provider: "provider", id: "model", ...metadata });
await waitFor(() =>
expect(
screen.getAllByRole("combobox", { name: "Thinking" }),
).toHaveLength(2),
);
for (const selector of screen.getAllByRole("combobox", {
name: "Thinking",
}) as HTMLSelectElement[])
expect(options(selector)).toEqual(expected);
},
);
});
+50
View File
@@ -194,6 +194,56 @@ describe("workspace reducer", () => {
]);
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
});
it("preserves reasoning model metadata from runtime snapshots", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
state: {
data: {
model: {
provider: "provider",
id: "reasoning",
reasoning: true,
thinkingLevelMap: { high: null, xhigh: "xhigh" },
},
},
},
models: {
data: {
models: [
{
provider: "provider",
id: "reasoning",
reasoning: true,
thinkingLevelMap: { high: null, xhigh: "xhigh" },
},
],
},
},
extensions: [],
},
});
expect(state.runtimesById.a.state.model).toEqual({
provider: "provider",
id: "reasoning",
reasoning: true,
thinkingLevelMap: { high: null, xhigh: "xhigh" },
});
expect(state.runtimesById.a.models[0]).toEqual(
state.runtimesById.a.state.model,
);
});
it("removes the optimistic submission when Pi returns its structured user message", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
+13 -8
View File
@@ -60,19 +60,23 @@ export type Message = {
| string
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
};
type ThinkingLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "max";
export type AgentState = {
isStreaming?: boolean;
thinkingLevel?: string;
thinkingLevel?: ThinkingLevel;
sessionName?: string;
sessionId?: string;
messageCount?: number;
pendingMessageCount?: number;
model?: {
provider?: string;
id?: string;
name?: string;
contextWindow?: number;
};
model?: Model & { contextWindow?: number };
};
export type SessionStats = {
tokens?: {
@@ -93,7 +97,8 @@ export type Model = {
provider?: string;
id?: string;
name?: string;
thinkingLevelMap?: Record<string, string>;
reasoning?: boolean;
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
};
export type Extension = {
id?: string;
+48 -1
View File
@@ -28,6 +28,9 @@ export function useWorkspace() {
const legacyMigrationPending = useRef(false);
const disposed = useRef(true);
const refreshGeneration = useRef(0);
const workspaceReady = useRef(false);
const pendingPromotedRuntime = useRef<string | undefined>(undefined);
const consumingPromotion = useRef(false);
const loadSnapshot = useCallback(
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
@@ -150,10 +153,44 @@ export function useWorkspace() {
[loadSnapshot],
);
const consumePromotedRuntime = useCallback(() => {
if (!workspaceReady.current || consumingPromotion.current) return;
consumingPromotion.current = true;
void (async () => {
while (pendingPromotedRuntime.current && !disposed.current) {
const runtimeId = pendingPromotedRuntime.current;
pendingPromotedRuntime.current = undefined;
await refresh();
if (disposed.current) return;
dispatch({ type: "selectRuntime", runtimeId });
await loadSnapshot(runtimeId);
if (disposed.current) return;
await invoke("consume_pending_promoted_runtime", { runtimeId });
}
})()
.catch(() => {})
.finally(() => {
consumingPromotion.current = false;
if (pendingPromotedRuntime.current) consumePromotedRuntime();
});
}, [loadSnapshot, refresh]);
useEffect(() => {
disposed.current = false;
workspaceReady.current = false;
let stop: (() => void) | undefined;
let stopNewRuntime: (() => void) | undefined;
let stopPromotedRuntime: (() => void) | undefined;
const promotedListenerReady = listen<{ runtimeId: string }>(
"workspace-promoted-runtime",
(message) => {
pendingPromotedRuntime.current = message.payload.runtimeId;
consumePromotedRuntime();
},
).then((unlisten) => {
if (disposed.current) unlisten();
else stopPromotedRuntime = unlisten;
});
void listen<{
phase: "starting" | "ready" | "error";
runtimeId?: string;
@@ -197,6 +234,7 @@ export function useWorkspace() {
"runtime_recovered",
"runtime_renewed",
"runtime_restore_failed",
"runtime_promoted",
].includes(eventType ?? "")
) {
void refresh();
@@ -215,10 +253,17 @@ export function useWorkspace() {
});
void (async () => {
try {
await promotedListenerReady;
const loaded = await loadPresentation();
if (disposed.current) return;
await refresh(loaded);
if (disposed.current) return;
workspaceReady.current = true;
const runtimeId = await invoke<string | null>(
"get_pending_promoted_runtime",
).catch(() => null);
if (runtimeId) pendingPromotedRuntime.current = runtimeId;
consumePromotedRuntime();
initialized.current = true;
} catch (error) {
if (!disposed.current)
@@ -243,9 +288,11 @@ export function useWorkspace() {
document.addEventListener("visibilitychange", visibility);
return () => {
disposed.current = true;
workspaceReady.current = false;
refreshGeneration.current += 1;
stop?.();
stopNewRuntime?.();
stopPromotedRuntime?.();
void invoke("unsubscribe_workspace").catch(() => {});
window.removeEventListener("focus", visibility);
window.removeEventListener("blur", visibility);
@@ -253,7 +300,7 @@ export function useWorkspace() {
Object.values(timers.current).forEach(clearTimeout);
void appWindow;
};
}, [refresh, scheduleSnapshot]);
}, [consumePromotedRuntime, refresh, scheduleSnapshot]);
useEffect(() => {
if (!initialized.current) return;