feat(quick-pane): promote active runtime
This commit is contained in:
+257
-16
@@ -5,8 +5,11 @@ import {
|
|||||||
readdir,
|
readdir,
|
||||||
readFile,
|
readFile,
|
||||||
realpath,
|
realpath,
|
||||||
|
rename,
|
||||||
rm,
|
rm,
|
||||||
stat,
|
stat,
|
||||||
|
symlink,
|
||||||
|
unlink,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
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) {
|
function sessionPreview(content) {
|
||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") return content;
|
||||||
if (!Array.isArray(content)) return "";
|
if (!Array.isArray(content)) return "";
|
||||||
@@ -245,6 +311,7 @@ export function createAgentRegistry({
|
|||||||
homeWorktree,
|
homeWorktree,
|
||||||
sessionRoot,
|
sessionRoot,
|
||||||
startAdapter = startPiRpcAdapter,
|
startAdapter = startPiRpcAdapter,
|
||||||
|
workspaceStoreFactory = createWorkspaceStore,
|
||||||
eventLimit = DEFAULT_EVENT_LIMIT,
|
eventLimit = DEFAULT_EVENT_LIMIT,
|
||||||
maxRecoveryAttempts,
|
maxRecoveryAttempts,
|
||||||
recoveryDelayForAttempt,
|
recoveryDelayForAttempt,
|
||||||
@@ -257,9 +324,11 @@ export function createAgentRegistry({
|
|||||||
throw new TypeError("sessionRoot must be an absolute path");
|
throw new TypeError("sessionRoot must be an absolute path");
|
||||||
if (typeof startAdapter !== "function")
|
if (typeof startAdapter !== "function")
|
||||||
throw new TypeError("startAdapter must be a 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 bridgeInstanceId = randomUUID();
|
||||||
const workspaceStore = createWorkspaceStore(sessionRoot);
|
const workspaceStore = workspaceStoreFactory(sessionRoot);
|
||||||
const runtimesByRuntimeId = new Map();
|
const runtimesByRuntimeId = new Map();
|
||||||
const runtimesByAgentId = new Map();
|
const runtimesByAgentId = new Map();
|
||||||
const runtimesByWorktreePath = new Map();
|
const runtimesByWorktreePath = new Map();
|
||||||
@@ -272,6 +341,7 @@ export function createAgentRegistry({
|
|||||||
const workspaceEvents = [];
|
const workspaceEvents = [];
|
||||||
let workspaceSequence = 0;
|
let workspaceSequence = 0;
|
||||||
let manifestIssue;
|
let manifestIssue;
|
||||||
|
let manifestWriteQueue = Promise.resolve();
|
||||||
let canonicalHomeWorktree;
|
let canonicalHomeWorktree;
|
||||||
let lastSuccessfulModelCatalog;
|
let lastSuccessfulModelCatalog;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
@@ -318,9 +388,15 @@ export function createAgentRegistry({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistWorkspace() {
|
function persistWorkspace() {
|
||||||
await workspaceStore.save(manifest());
|
const operation = manifestWriteQueue
|
||||||
manifestIssue = undefined;
|
.catch(() => {})
|
||||||
|
.then(async () => {
|
||||||
|
await workspaceStore.save(manifest());
|
||||||
|
manifestIssue = undefined;
|
||||||
|
});
|
||||||
|
manifestWriteQueue = operation.catch(() => {});
|
||||||
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWorktreeRuntime(runtime) {
|
function addWorktreeRuntime(runtime) {
|
||||||
@@ -430,10 +506,23 @@ export function createAgentRegistry({
|
|||||||
return resolvedSessionPath;
|
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(
|
async function updateRuntimeIdentity(
|
||||||
runtime,
|
runtime,
|
||||||
stateResponse,
|
stateResponse,
|
||||||
{ expectedSessionPath, adapter = runtime.adapter } = {},
|
{
|
||||||
|
expectedSessionPath,
|
||||||
|
adapter = runtime.adapter,
|
||||||
|
persist = true,
|
||||||
|
indexSession = true,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
runtime.stopped ||
|
runtime.stopped ||
|
||||||
@@ -477,15 +566,17 @@ export function createAgentRegistry({
|
|||||||
runtime.sessionName = state.sessionName;
|
runtime.sessionName = state.sessionName;
|
||||||
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
if (
|
if (indexSession) {
|
||||||
runtime.sessionPath &&
|
if (
|
||||||
runtime.sessionPath !== resolved &&
|
runtime.sessionPath &&
|
||||||
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
runtime.sessionPath !== resolved &&
|
||||||
)
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
||||||
runtimesBySessionPath.delete(runtime.sessionPath);
|
)
|
||||||
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
||||||
|
runtimesBySessionPath.set(resolved, runtime);
|
||||||
|
releaseSessionReservation(resolved, runtime);
|
||||||
|
}
|
||||||
runtime.sessionPath = resolved;
|
runtime.sessionPath = resolved;
|
||||||
runtimesBySessionPath.set(resolved, runtime);
|
|
||||||
releaseSessionReservation(resolved, runtime);
|
|
||||||
if (runtime.legacyDefault)
|
if (runtime.legacyDefault)
|
||||||
await persistSessionReference(
|
await persistSessionReference(
|
||||||
runtime.sessionDir,
|
runtime.sessionDir,
|
||||||
@@ -493,7 +584,16 @@ export function createAgentRegistry({
|
|||||||
runtime.worktreePath,
|
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) {
|
function publishAgent(runtime, type, data) {
|
||||||
@@ -587,6 +687,8 @@ export function createAgentRegistry({
|
|||||||
adapter: undefined,
|
adapter: undefined,
|
||||||
supervisor: undefined,
|
supervisor: undefined,
|
||||||
stopped: false,
|
stopped: false,
|
||||||
|
promoting: false,
|
||||||
|
promotionPromise: undefined,
|
||||||
recoveryPromise: undefined,
|
recoveryPromise: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1000,6 +1102,8 @@ export function createAgentRegistry({
|
|||||||
|
|
||||||
function closeRuntime(runtimeId) {
|
function closeRuntime(runtimeId) {
|
||||||
const runtime = getRuntime(runtimeId);
|
const runtime = getRuntime(runtimeId);
|
||||||
|
if (runtime.promoting)
|
||||||
|
return runtime.promotionPromise.then(() => closeRuntime(runtimeId));
|
||||||
if (runtime.state === "closing" || runtime.state === "stopped")
|
if (runtime.state === "closing" || runtime.state === "stopped")
|
||||||
return Promise.reject(new Error("session runtime is closing"));
|
return Promise.reject(new Error("session runtime is closing"));
|
||||||
const wasWorkingAtRequest =
|
const wasWorkingAtRequest =
|
||||||
@@ -1018,6 +1122,136 @@ export function createAgentRegistry({
|
|||||||
return result;
|
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(
|
async function refreshAfterLegacySessionChange(
|
||||||
runtime,
|
runtime,
|
||||||
response,
|
response,
|
||||||
@@ -1208,13 +1442,19 @@ export function createAgentRegistry({
|
|||||||
async createQuickRuntime(worktreePath) {
|
async createQuickRuntime(worktreePath) {
|
||||||
return openRuntime({ worktreePath, ephemeral: true });
|
return openRuntime({ worktreePath, ephemeral: true });
|
||||||
},
|
},
|
||||||
|
promoteQuickRuntime,
|
||||||
async openSessionRuntime(worktreePath, sessionPath) {
|
async openSessionRuntime(worktreePath, sessionPath) {
|
||||||
return openRuntime({ worktreePath, sessionPath });
|
return openRuntime({ worktreePath, sessionPath });
|
||||||
},
|
},
|
||||||
closeSessionRuntime: closeRuntime,
|
closeSessionRuntime: closeRuntime,
|
||||||
async closeQuickRuntime(runtimeId) {
|
async closeQuickRuntime(runtimeId) {
|
||||||
const runtime = getRuntime(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);
|
return closeRuntime(runtimeId);
|
||||||
},
|
},
|
||||||
async getModelCatalog() {
|
async getModelCatalog() {
|
||||||
@@ -1478,7 +1718,8 @@ export function createAgentRegistry({
|
|||||||
},
|
},
|
||||||
async route(agentId, operation, payload = {}) {
|
async route(agentId, operation, payload = {}) {
|
||||||
const runtime = getAgent(agentId);
|
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 () => {
|
return enqueue(runtime, async () => {
|
||||||
if (operation === "switch_session") {
|
if (operation === "switch_session") {
|
||||||
if (runtime.state === "streaming")
|
if (runtime.state === "streaming")
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export async function startBridgeService({
|
|||||||
request.payload.worktreePath,
|
request.payload.worktreePath,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
case "promote_quick_runtime":
|
||||||
|
return {
|
||||||
|
runtime: await registry.promoteQuickRuntime(request.payload.runtimeId),
|
||||||
|
};
|
||||||
case "open_session_runtime":
|
case "open_session_runtime":
|
||||||
return {
|
return {
|
||||||
runtime: await registry.openSessionRuntime(
|
runtime: await registry.openSessionRuntime(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const requestOperations = new Map([
|
|||||||
["get_model_catalog", { agent: false, payload: "none" }],
|
["get_model_catalog", { agent: false, payload: "none" }],
|
||||||
["create_session_runtime", { agent: false, payload: "worktree" }],
|
["create_session_runtime", { agent: false, payload: "worktree" }],
|
||||||
["create_quick_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" }],
|
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
|
||||||
["close_session_runtime", { agent: false, payload: "runtime" }],
|
["close_session_runtime", { agent: false, payload: "runtime" }],
|
||||||
["close_quick_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: "get_workspace_summary" },
|
||||||
{ op: "create_session_runtime", payload: { worktreePath } },
|
{ op: "create_session_runtime", payload: { worktreePath } },
|
||||||
{ op: "create_quick_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: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
||||||
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
||||||
{ op: "close_quick_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 { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
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(
|
const source = await readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
"utf8",
|
"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-notice escalation"/);
|
||||||
assert.match(source, /className="quick-pane-assistant-footer"/);
|
assert.match(source, /className="quick-pane-assistant-footer"/);
|
||||||
assert.match(source, /Continue in full session/);
|
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/);
|
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);
|
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 () => {
|
test("Quick Pane routes extension responses through existing bridge command", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
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
|
.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(
|
pub async fn open_session_runtime(
|
||||||
socket_path: &str,
|
socket_path: &str,
|
||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
|
|||||||
+101
-1
@@ -41,6 +41,7 @@ impl ManagedWindowVisibility {
|
|||||||
}
|
}
|
||||||
// One mutex serializes cross-window transitions, avoiding nested visibility-lock ordering.
|
// One mutex serializes cross-window transitions, avoiding nested visibility-lock ordering.
|
||||||
struct WindowVisibility(Mutex<ManagedWindowVisibility>);
|
struct WindowVisibility(Mutex<ManagedWindowVisibility>);
|
||||||
|
struct PendingPromotedRuntime(Mutex<Option<String>>);
|
||||||
struct WorkspaceSubscription {
|
struct WorkspaceSubscription {
|
||||||
generation: Arc<bridge::SubscriptionGeneration>,
|
generation: Arc<bridge::SubscriptionGeneration>,
|
||||||
task: Mutex<Option<JoinHandle<()>>>,
|
task: Mutex<Option<JoinHandle<()>>>,
|
||||||
@@ -96,7 +97,10 @@ fn should_hide_close_request(label: &str) -> bool {
|
|||||||
label == "main" || label == "quick-pane"
|
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") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
return Ok((window, false));
|
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())?;
|
.ok_or_else(|| "Main window configuration is missing".to_owned())?;
|
||||||
let window = WebviewWindowBuilder::from_config(app, config)
|
let window = WebviewWindowBuilder::from_config(app, config)
|
||||||
.map_err(|error| error.to_string())?
|
.map_err(|error| error.to_string())?
|
||||||
|
.visible(visible_when_created)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
Ok((window, true))
|
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]) {
|
fn apply_main_window_action(app: &AppHandle, args: &[String]) {
|
||||||
let (window, created) = match get_or_create_main(app) {
|
let (window, created) = match get_or_create_main(app) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
@@ -174,6 +183,44 @@ fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), St
|
|||||||
Ok((window, true))
|
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) {
|
fn toggle_quick_pane(app: &AppHandle) {
|
||||||
let (window, created) = match get_or_create_quick_pane(app) {
|
let (window, created) = match get_or_create_quick_pane(app) {
|
||||||
Ok(result) => result,
|
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
|
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]
|
#[tauri::command]
|
||||||
async fn open_session_runtime(
|
async fn open_session_runtime(
|
||||||
worktree_path: String,
|
worktree_path: String,
|
||||||
@@ -582,6 +668,7 @@ pub fn run() {
|
|||||||
main: true,
|
main: true,
|
||||||
quick_pane: false,
|
quick_pane: false,
|
||||||
})))
|
})))
|
||||||
|
.manage(PendingPromotedRuntime(Mutex::new(None)))
|
||||||
.manage(WorkspaceSubscription {
|
.manage(WorkspaceSubscription {
|
||||||
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
||||||
task: Mutex::new(None),
|
task: Mutex::new(None),
|
||||||
@@ -628,6 +715,9 @@ pub fn run() {
|
|||||||
dismiss_quick_pane,
|
dismiss_quick_pane,
|
||||||
create_session_runtime,
|
create_session_runtime,
|
||||||
create_quick_runtime,
|
create_quick_runtime,
|
||||||
|
promote_quick_runtime,
|
||||||
|
get_pending_promoted_runtime,
|
||||||
|
consume_pending_promoted_runtime,
|
||||||
open_session_runtime,
|
open_session_runtime,
|
||||||
close_session_runtime,
|
close_session_runtime,
|
||||||
close_quick_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]
|
#[test]
|
||||||
fn dismissed_quick_pane_reopens_with_one_toggle() {
|
fn dismissed_quick_pane_reopens_with_one_toggle() {
|
||||||
let mut visibility = ManagedWindowVisibility {
|
let mut visibility = ManagedWindowVisibility {
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export function ConversationWorkspace({
|
|||||||
pending={isModelUpdating || locked}
|
pending={isModelUpdating || locked}
|
||||||
/>
|
/>
|
||||||
<ThinkingSelector
|
<ThinkingSelector
|
||||||
model={selectedThinkingModel}
|
model={selectedThinkingModel ?? runtime.state.model}
|
||||||
value={runtime.state.thinkingLevel ?? "medium"}
|
value={runtime.state.thinkingLevel ?? "medium"}
|
||||||
disabled={isModelUpdating || locked}
|
disabled={isModelUpdating || locked}
|
||||||
onChange={(level) => void onThinking(level)}
|
onChange={(level) => void onThinking(level)}
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ it("uses selected model thinking capabilities and reconciles unavailable values"
|
|||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ThinkingSelector
|
<ThinkingSelector
|
||||||
model={{ thinkingLevelMap: { off: "off", high: "high" } }}
|
model={{
|
||||||
|
reasoning: true,
|
||||||
|
thinkingLevelMap: {
|
||||||
|
minimal: null,
|
||||||
|
low: null,
|
||||||
|
medium: null,
|
||||||
|
high: "high",
|
||||||
|
},
|
||||||
|
}}
|
||||||
value="medium"
|
value="medium"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>,
|
/>,
|
||||||
@@ -15,7 +23,9 @@ it("uses selected model thinking capabilities and reconciles unavailable values"
|
|||||||
const selector = screen.getByRole("combobox", { name: "Thinking" });
|
const selector = screen.getByRole("combobox", { name: "Thinking" });
|
||||||
expect(selector).toHaveValue("off");
|
expect(selector).toHaveValue("off");
|
||||||
expect(
|
expect(
|
||||||
within(selector).getAllByRole("option").map((option) => option.textContent),
|
within(selector)
|
||||||
|
.getAllByRole("option")
|
||||||
|
.map((option) => option.textContent),
|
||||||
).toEqual(["Off", "High"]);
|
).toEqual(["Off", "High"]);
|
||||||
fireEvent.change(selector, { target: { value: "high" } });
|
fireEvent.change(selector, { target: { value: "high" } });
|
||||||
expect(onChange).toHaveBeenCalledWith("high");
|
expect(onChange).toHaveBeenCalledWith("high");
|
||||||
|
|||||||
@@ -178,6 +178,35 @@ describe("multi-session navigation", () => {
|
|||||||
await user.click(screen.getByText("Tool result · read"));
|
await user.click(screen.getByText("Tool result · read"));
|
||||||
expect(details).toHaveAttribute("open");
|
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", () => {
|
it("packs session status between model controls and the composer", () => {
|
||||||
const runtime = runtimeView(summary("status", "streaming"));
|
const runtime = runtimeView(summary("status", "streaming"));
|
||||||
runtime.summary.queueCount = 2;
|
runtime.summary.queueCount = 2;
|
||||||
|
|||||||
@@ -6,29 +6,60 @@ import {
|
|||||||
} from "./model-catalog";
|
} from "./model-catalog";
|
||||||
|
|
||||||
describe("thinking model catalog metadata", () => {
|
describe("thinking model catalog metadata", () => {
|
||||||
it("uses catalog thinking levels and reconciles removed selections", () => {
|
it("matches Pi reasoning defaults, null exclusions, and explicit max", () => {
|
||||||
const model = {
|
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
|
||||||
provider: "provider",
|
expect(thinkingLevelOptions({ reasoning: false })).toEqual([
|
||||||
id: "reasoning",
|
|
||||||
thinkingLevelMap: { off: "off", low: "low", high: "high" },
|
|
||||||
};
|
|
||||||
expect(thinkingLevelOptions(model)).toEqual([
|
|
||||||
{ value: "off", label: "Off" },
|
{ value: "off", label: "Off" },
|
||||||
|
]);
|
||||||
|
expect(thinkingLevelOptions({})).toEqual([
|
||||||
|
{ value: "off", label: "Off" },
|
||||||
|
{ value: "minimal", label: "Minimal" },
|
||||||
{ value: "low", label: "Low" },
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
{ value: "high", label: "High" },
|
{ value: "high", label: "High" },
|
||||||
]);
|
]);
|
||||||
expect(reconcileThinkingLevel(model, "high")).toBe("high");
|
const mapped = {
|
||||||
expect(reconcileThinkingLevel(model, "medium")).toBe("off");
|
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", () => {
|
it("treats dynamically configured models with omitted reasoning as capable", () => {
|
||||||
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
|
const [dynamic] = catalogModels(
|
||||||
const [dynamic] = catalogModels([], [
|
[],
|
||||||
{ provider: "localai", modelId: "local/unknown" },
|
[{ provider: "localai", modelId: "local/unknown" }],
|
||||||
]);
|
);
|
||||||
expect(dynamic.thinkingLevelMap).toEqual({ off: "off" });
|
expect(dynamic.thinkingLevelMap).toBeUndefined();
|
||||||
expect(thinkingLevelOptions(dynamic)).toEqual([
|
expect(thinkingLevelOptions(dynamic)).toEqual([
|
||||||
{ value: "off", label: "Off" },
|
{ 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 ModelIdentity = { provider: string; modelId: string };
|
||||||
export type ThinkingLevelOption = { value: string; label: 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" };
|
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 {
|
export function modelIdentity(model: Model): ModelIdentity | undefined {
|
||||||
if (!model.provider || !model.id) return undefined;
|
if (!model.provider || !model.id) return undefined;
|
||||||
return { provider: model.provider, modelId: model.id };
|
return { provider: model.provider, modelId: model.id };
|
||||||
@@ -49,22 +62,29 @@ export function reconcileModelIdentity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] {
|
export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] {
|
||||||
const map = model?.thinkingLevelMap;
|
if (!model || model.reasoning === false) return [OFF_THINKING_LEVEL];
|
||||||
if (!map || typeof map !== "object") return [OFF_THINKING_LEVEL];
|
const map = model.thinkingLevelMap;
|
||||||
const options = Object.keys(map)
|
const levels = [
|
||||||
.filter((level) => level.trim() && typeof map[level] === "string")
|
...DEFAULT_THINKING_LEVELS.filter((level) => {
|
||||||
.map((level) => ({
|
if (level === "xhigh") return map?.xhigh !== undefined && map.xhigh !== null;
|
||||||
value: level,
|
return map?.[level] !== null;
|
||||||
label: level === "off" ? "Off" : level[0].toUpperCase() + level.slice(1),
|
}),
|
||||||
}));
|
...(map?.max == null ? [] : ["max"]),
|
||||||
return options.length ? options : [OFF_THINKING_LEVEL];
|
];
|
||||||
|
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);
|
const options = thinkingLevelOptions(model);
|
||||||
return options.some((option) => option.value === level)
|
return options.some((option) => option.value === level)
|
||||||
? 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(
|
export function catalogModels(
|
||||||
@@ -85,8 +105,6 @@ export function catalogModels(
|
|||||||
available.unshift({
|
available.unshift({
|
||||||
provider: identity.provider,
|
provider: identity.provider,
|
||||||
id: identity.modelId,
|
id: identity.modelId,
|
||||||
// Configured/local models are not catalog-confirmed. Do not imply thinking support.
|
|
||||||
thinkingLevelMap: { off: "off" },
|
|
||||||
});
|
});
|
||||||
keys.add(key);
|
keys.add(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,7 +389,8 @@ describe("QuickPane", () => {
|
|||||||
provider: "provider",
|
provider: "provider",
|
||||||
id: "quick/model",
|
id: "quick/model",
|
||||||
name: "Quick Model",
|
name: "Quick Model",
|
||||||
thinkingLevelMap: { off: "off", high: "high" },
|
reasoning: true,
|
||||||
|
thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
bridgeMock([thinkingModels], () =>
|
bridgeMock([thinkingModels], () =>
|
||||||
@@ -663,23 +664,13 @@ describe("QuickPane", () => {
|
|||||||
expect(response.scrollTop).toBe(100);
|
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], () =>
|
bridgeMock([defaultModels], () =>
|
||||||
snapshotWithTranscript([
|
snapshotWithTranscript([
|
||||||
{ role: "user", content: "Inspect" },
|
{ role: "user", content: "Inspect" },
|
||||||
{ role: "assistant", content: "Completed answer" },
|
{ 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 />);
|
render(<QuickPane />);
|
||||||
await preparedTrigger();
|
await preparedTrigger();
|
||||||
@@ -690,19 +681,26 @@ describe("QuickPane", () => {
|
|||||||
expect(
|
expect(
|
||||||
continueButton.closest(".quick-pane-assistant-footer"),
|
continueButton.closest(".quick-pane-assistant-footer"),
|
||||||
).not.toBeNull();
|
).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(
|
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||||
"create_session_runtime",
|
"create_session_runtime",
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
await userEvent.click(continueButton);
|
mocks.invoke.mock.calls.filter(
|
||||||
await waitFor(() =>
|
([command]) => command === "submit_prompt",
|
||||||
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
|
),
|
||||||
agentId: "agent-full",
|
).toHaveLength(submittedPromptCount);
|
||||||
message:
|
|
||||||
"Inspect\n\nQuick Pane handoff: Continue this chat in a full session.\n\nQuick Pane transcript:\nUser: Inspect\n\nAssistant: Completed answer",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps unlimited follow-ups on one runtime and renders snapshot messages in order", async () => {
|
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();
|
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||||
mocks.invoke.mockImplementation(
|
mocks.invoke.mockImplementation(
|
||||||
(command: string, payload?: Record<string, unknown>) => {
|
(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")
|
if (command === "submit_prompt" && payload?.agentId === "agent-1")
|
||||||
submittedPrompts.push(String(payload.message));
|
submittedPrompts.push(String(payload.message));
|
||||||
return baseImplementation?.(command, payload);
|
return baseImplementation?.(command, payload);
|
||||||
@@ -1011,18 +1005,18 @@ describe("QuickPane", () => {
|
|||||||
await screen.findByText("Second runtime failed."),
|
await screen.findByText("Second runtime failed."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("First answer")).toBeInTheDocument();
|
expect(screen.getByText("First answer")).toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
|
await userEvent.click(
|
||||||
|
within(screen.getByRole("status")).getByRole("button", {
|
||||||
await waitFor(() =>
|
name: "Continue",
|
||||||
expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", {
|
|
||||||
worktreePath: "/repo",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
|
|
||||||
agentId: "agent-full",
|
await waitFor(() =>
|
||||||
message:
|
expect(mocks.invoke).toHaveBeenCalledWith("promote_quick_runtime", {
|
||||||
"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",
|
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 () => {
|
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 [submissionStarting, setSubmissionStarting] = useState(false);
|
||||||
const [requestActive, setRequestActive] = useState(false);
|
const [requestActive, setRequestActive] = useState(false);
|
||||||
const [chatReplacing, setChatReplacing] = useState(false);
|
const [chatReplacing, setChatReplacing] = useState(false);
|
||||||
|
const [promoting, setPromoting] = useState(false);
|
||||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||||
const [models, setModels] = useState<Model[]>([]);
|
const [models, setModels] = useState<Model[]>([]);
|
||||||
const [catalogStatus, setCatalogStatus] = useState<
|
const [catalogStatus, setCatalogStatus] = useState<
|
||||||
@@ -229,7 +230,6 @@ export default function QuickPane() {
|
|||||||
const [catalogNotice, setCatalogNotice] = useState<string>();
|
const [catalogNotice, setCatalogNotice] = useState<string>();
|
||||||
const [runtime, setRuntime] = useState<Runtime>();
|
const [runtime, setRuntime] = useState<Runtime>();
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
const [answer, setAnswer] = useState("");
|
|
||||||
const [responseComplete, setResponseComplete] = useState(false);
|
const [responseComplete, setResponseComplete] = useState(false);
|
||||||
const [escalation, setEscalation] = useState<string>();
|
const [escalation, setEscalation] = useState<string>();
|
||||||
const [extension, setExtension] = useState<Extension>();
|
const [extension, setExtension] = useState<Extension>();
|
||||||
@@ -634,7 +634,6 @@ export default function QuickPane() {
|
|||||||
setRequestActive(false);
|
setRequestActive(false);
|
||||||
setSubmissionStarting(false);
|
setSubmissionStarting(false);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setAnswer("");
|
|
||||||
setResponseComplete(false);
|
setResponseComplete(false);
|
||||||
setEscalation(undefined);
|
setEscalation(undefined);
|
||||||
setExtension(undefined);
|
setExtension(undefined);
|
||||||
@@ -894,8 +893,6 @@ export default function QuickPane() {
|
|||||||
activeTool: snapshot.runtime.activeTool,
|
activeTool: snapshot.runtime.activeTool,
|
||||||
hasAssistantText: !!text,
|
hasAssistantText: !!text,
|
||||||
});
|
});
|
||||||
if (promptSubmittedRuntimeRef.current === runtime.runtimeId)
|
|
||||||
setAnswer(result);
|
|
||||||
setExtension(snapshot.extensions[0]);
|
setExtension(snapshot.extensions[0]);
|
||||||
if (
|
if (
|
||||||
complete &&
|
complete &&
|
||||||
@@ -983,7 +980,6 @@ export default function QuickPane() {
|
|||||||
};
|
};
|
||||||
promptSubmittedRuntimeRef.current = undefined;
|
promptSubmittedRuntimeRef.current = undefined;
|
||||||
completionHandledRef.current = false;
|
completionHandledRef.current = false;
|
||||||
setAnswer("");
|
|
||||||
setResponseComplete(false);
|
setResponseComplete(false);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setEscalation(undefined);
|
setEscalation(undefined);
|
||||||
@@ -1050,9 +1046,7 @@ export default function QuickPane() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handoffRequest =
|
const canContinue = !invalid && !!runtime && !promoting;
|
||||||
request.trim() || submittedRequestRef.current?.request || "";
|
|
||||||
const canContinue = !invalid && !!handoffRequest;
|
|
||||||
const completedAssistantAnswer =
|
const completedAssistantAnswer =
|
||||||
responseComplete &&
|
responseComplete &&
|
||||||
messages.some((message) => message.role === "assistant");
|
messages.some((message) => message.role === "assistant");
|
||||||
@@ -1069,35 +1063,22 @@ export default function QuickPane() {
|
|||||||
responseComplete ||
|
responseComplete ||
|
||||||
submittedRequestRef.current !== undefined;
|
submittedRequestRef.current !== undefined;
|
||||||
const hasTranscript = messages.length > 0 || runActive || responseComplete;
|
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() {
|
async function continueFull() {
|
||||||
if (invalid || !handoffRequest)
|
const current = runtimeRef.current;
|
||||||
return setError(invalid ?? "Enter a request.");
|
if (invalid || !current) return setError(invalid ?? "Quick runtime is unavailable.");
|
||||||
|
setPromoting(true);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<{ runtime: Runtime }>(
|
await invoke<{ runtime: Runtime }>("promote_quick_runtime", {
|
||||||
"create_session_runtime",
|
runtimeId: current.runtimeId,
|
||||||
{
|
|
||||||
worktreePath: settings.workspace,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await invoke("set_model", {
|
|
||||||
agentId: result.runtime.agentId,
|
|
||||||
provider: settings.followUpProvider,
|
|
||||||
modelId: settings.followUpModel,
|
|
||||||
});
|
});
|
||||||
await invoke("submit_prompt", {
|
cleanupQueueRef.current.delete(current.runtimeId);
|
||||||
agentId: result.runtime.agentId,
|
clearRuntimeIfCurrent(current.runtimeId);
|
||||||
message: `${handoffRequest}\n\nQuick Pane handoff: ${escalation ?? "Continue this chat in a full session."}\n\nQuick Pane transcript:\n${transcriptHandoff || answer}`,
|
await dismissPane();
|
||||||
});
|
|
||||||
setNotice("Full session started with follow-up model.");
|
|
||||||
} catch (reason) {
|
} 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>
|
||||||
<div className="quick-pane-header-actions">
|
<div className="quick-pane-header-actions">
|
||||||
|
{runtime && (
|
||||||
|
<button
|
||||||
|
className="quick-pane-action"
|
||||||
|
type="button"
|
||||||
|
disabled={catalogOverlayActive || promoting}
|
||||||
|
onClick={continueFull}
|
||||||
|
>
|
||||||
|
{promoting ? "Continuing…" : "Continue"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="quick-pane-close"
|
className="quick-pane-close"
|
||||||
type="button"
|
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");
|
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", () => {
|
it("removes the optimistic submission when Pi returns its structured user message", () => {
|
||||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
state = workspaceReducer(state, {
|
state = workspaceReducer(state, {
|
||||||
|
|||||||
@@ -60,19 +60,23 @@ export type Message = {
|
|||||||
| string
|
| string
|
||||||
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
||||||
};
|
};
|
||||||
|
type ThinkingLevel =
|
||||||
|
| "off"
|
||||||
|
| "minimal"
|
||||||
|
| "low"
|
||||||
|
| "medium"
|
||||||
|
| "high"
|
||||||
|
| "xhigh"
|
||||||
|
| "max";
|
||||||
|
|
||||||
export type AgentState = {
|
export type AgentState = {
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: ThinkingLevel;
|
||||||
sessionName?: string;
|
sessionName?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
messageCount?: number;
|
messageCount?: number;
|
||||||
pendingMessageCount?: number;
|
pendingMessageCount?: number;
|
||||||
model?: {
|
model?: Model & { contextWindow?: number };
|
||||||
provider?: string;
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
contextWindow?: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
export type SessionStats = {
|
export type SessionStats = {
|
||||||
tokens?: {
|
tokens?: {
|
||||||
@@ -93,7 +97,8 @@ export type Model = {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
thinkingLevelMap?: Record<string, string>;
|
reasoning?: boolean;
|
||||||
|
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
|
||||||
};
|
};
|
||||||
export type Extension = {
|
export type Extension = {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function useWorkspace() {
|
|||||||
const legacyMigrationPending = useRef(false);
|
const legacyMigrationPending = useRef(false);
|
||||||
const disposed = useRef(true);
|
const disposed = useRef(true);
|
||||||
const refreshGeneration = useRef(0);
|
const refreshGeneration = useRef(0);
|
||||||
|
const workspaceReady = useRef(false);
|
||||||
|
const pendingPromotedRuntime = useRef<string | undefined>(undefined);
|
||||||
|
const consumingPromotion = useRef(false);
|
||||||
|
|
||||||
const loadSnapshot = useCallback(
|
const loadSnapshot = useCallback(
|
||||||
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
||||||
@@ -150,10 +153,44 @@ export function useWorkspace() {
|
|||||||
[loadSnapshot],
|
[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(() => {
|
useEffect(() => {
|
||||||
disposed.current = false;
|
disposed.current = false;
|
||||||
|
workspaceReady.current = false;
|
||||||
let stop: (() => void) | undefined;
|
let stop: (() => void) | undefined;
|
||||||
let stopNewRuntime: (() => 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<{
|
void listen<{
|
||||||
phase: "starting" | "ready" | "error";
|
phase: "starting" | "ready" | "error";
|
||||||
runtimeId?: string;
|
runtimeId?: string;
|
||||||
@@ -197,6 +234,7 @@ export function useWorkspace() {
|
|||||||
"runtime_recovered",
|
"runtime_recovered",
|
||||||
"runtime_renewed",
|
"runtime_renewed",
|
||||||
"runtime_restore_failed",
|
"runtime_restore_failed",
|
||||||
|
"runtime_promoted",
|
||||||
].includes(eventType ?? "")
|
].includes(eventType ?? "")
|
||||||
) {
|
) {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -215,10 +253,17 @@ export function useWorkspace() {
|
|||||||
});
|
});
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
await promotedListenerReady;
|
||||||
const loaded = await loadPresentation();
|
const loaded = await loadPresentation();
|
||||||
if (disposed.current) return;
|
if (disposed.current) return;
|
||||||
await refresh(loaded);
|
await refresh(loaded);
|
||||||
if (disposed.current) return;
|
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;
|
initialized.current = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!disposed.current)
|
if (!disposed.current)
|
||||||
@@ -243,9 +288,11 @@ export function useWorkspace() {
|
|||||||
document.addEventListener("visibilitychange", visibility);
|
document.addEventListener("visibilitychange", visibility);
|
||||||
return () => {
|
return () => {
|
||||||
disposed.current = true;
|
disposed.current = true;
|
||||||
|
workspaceReady.current = false;
|
||||||
refreshGeneration.current += 1;
|
refreshGeneration.current += 1;
|
||||||
stop?.();
|
stop?.();
|
||||||
stopNewRuntime?.();
|
stopNewRuntime?.();
|
||||||
|
stopPromotedRuntime?.();
|
||||||
void invoke("unsubscribe_workspace").catch(() => {});
|
void invoke("unsubscribe_workspace").catch(() => {});
|
||||||
window.removeEventListener("focus", visibility);
|
window.removeEventListener("focus", visibility);
|
||||||
window.removeEventListener("blur", visibility);
|
window.removeEventListener("blur", visibility);
|
||||||
@@ -253,7 +300,7 @@ export function useWorkspace() {
|
|||||||
Object.values(timers.current).forEach(clearTimeout);
|
Object.values(timers.current).forEach(clearTimeout);
|
||||||
void appWindow;
|
void appWindow;
|
||||||
};
|
};
|
||||||
}, [refresh, scheduleSnapshot]);
|
}, [consumePromotedRuntime, refresh, scheduleSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized.current) return;
|
if (!initialized.current) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user