feat(quick-pane): promote active runtime
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user