Merge remote-tracking branch 'origin/main' into merge/main-into-dev

# Conflicts:
#	CLAUDE.md
#	nix/package.nix
#	packages/app/src/components/combined-model-selector.tsx
#	packages/app/src/hooks/use-sidebar-workspaces-list.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/app/src/stores/session-store.test.ts
#	packages/server/src/client/daemon-client.ts
#	packages/server/src/server/agent/agent-manager.ts
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspace-git-watch.test.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/shared/messages.ts
This commit is contained in:
Mohamed Boudra
2026-04-08 11:38:17 +07:00
90 changed files with 6284 additions and 923 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.48",
"version": "0.1.49",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -61,8 +61,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.48",
"@getpaseo/relay": "0.1.48",
"@getpaseo/highlight": "0.1.49",
"@getpaseo/relay": "0.1.49",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
+47 -8
View File
@@ -34,6 +34,8 @@ import type {
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
ListAvailableEditorsResponseMessage,
OpenInEditorResponseMessage,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
@@ -55,6 +57,7 @@ import type {
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
EditorTargetId,
} from "../shared/messages.js";
import type {
AgentPermissionRequest,
@@ -478,9 +481,12 @@ export type InspectScheduleOptions = {
id: string;
requestId?: string;
};
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
export type FetchAgentResult = {
agent: AgentSnapshotPayload;
@@ -618,7 +624,10 @@ export class DaemonClient {
private connectionState: ConnectionState = { status: "idle" };
private checkoutDiffSubscriptions = new Map<
string,
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
private terminalDirectorySubscriptions = new Set<string>();
private terminalSlots = new Map<string, number>();
@@ -1339,8 +1348,36 @@ export class DaemonClient {
});
}
async listAvailableEditors(requestId?: string): Promise<ListAvailableEditorsPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "list_available_editors_request",
},
responseType: "list_available_editors_response",
timeout: 10000,
});
}
async openInEditor(
path: string,
editorId: EditorTargetId,
requestId?: string,
): Promise<OpenInEditorPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "open_in_editor_request",
path,
editorId,
},
responseType: "open_in_editor_response",
timeout: 10000,
});
}
async archiveWorkspace(
workspaceId: number,
workspaceId: string | number,
requestId?: string,
): Promise<ArchiveWorkspacePayload> {
return this.sendCorrelatedSessionRequest({
@@ -2185,20 +2222,22 @@ export class DaemonClient {
private normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } {
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
if (!trimmedBaseRef) {
return { mode: "base" };
return { mode: "base", ignoreWhitespace };
}
return { mode: "base", baseRef: trimmedBaseRef };
return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace };
}
async getCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string },
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
requestId?: string,
): Promise<CheckoutDiffPayload> {
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
@@ -2224,7 +2263,7 @@ export class DaemonClient {
async subscribeCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string },
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
options?: { subscriptionId?: string; requestId?: string },
): Promise<SubscribeCheckoutDiffPayload> {
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
@@ -174,6 +174,9 @@ function sanitizePermissionRequest(
if (sanitized.suggestions === undefined) {
delete sanitized.suggestions;
}
if (sanitized.actions === undefined) {
delete sanitized.actions;
}
if (sanitized.metadata === undefined) {
delete sanitized.metadata;
}
@@ -3459,6 +3459,213 @@ describe("AgentManager", () => {
expect(persisted?.lastModeId).toBe("acceptEdits");
});
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class RefreshingPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: true }),
];
private modeId = "auto";
private pending = [
{
id: "perm-plan-1",
provider: "codex" as const,
name: "CodexPlanApproval",
kind: "plan" as const,
input: { plan: "- Implement the feature" },
},
];
get features(): AgentFeature[] {
return this.featureState;
}
override async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: "gpt-5.4",
modeId: this.modeId,
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
};
}
override async getCurrentMode() {
return this.modeId;
}
override getPendingPermissions() {
return this.pending;
}
override async respondToPermission(): Promise<void> {
this.modeId = "auto";
this.pending = [];
this.featureState = [
createFeature({ id: "fast_mode", label: "Fast", value: false }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
];
}
}
class RefreshingPermissionClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new RefreshingPermissionSession(config);
}
}
const manager = new AgentManager({
clients: {
codex: new RefreshingPermissionClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000133",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const agent = manager.getAgent(snapshot.id);
if (!agent) {
throw new Error("Expected managed agent");
}
agent.pendingPermissions.set("perm-plan-1", {
id: "perm-plan-1",
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: { plan: "- Implement the feature" },
});
await manager.respondToPermission(snapshot.id, "perm-plan-1", {
behavior: "allow",
selectedActionId: "implement",
});
const updated = manager.getAgent(snapshot.id);
expect(updated?.pendingPermissions.size).toBe(0);
expect(updated?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: false }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
expect(updated?.runtimeInfo).toMatchObject({
model: "gpt-5.4",
extra: { collaborationMode: "Code" },
});
const persisted = await storage.get(snapshot.id);
expect(persisted?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: false }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
});
test("respondToPermission emits refreshed state before permission_resolved", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class OrderedPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
];
private modeId = "plan";
private pending = [
{
id: "perm-order-1",
provider: "codex" as const,
name: "ExitPlanMode",
kind: "plan" as const,
input: { plan: "- Do the work" },
},
];
get features(): AgentFeature[] {
return this.featureState;
}
override async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: "gpt-5.4",
modeId: this.modeId,
};
}
override async getCurrentMode() {
return this.modeId;
}
override getPendingPermissions() {
return this.pending;
}
override async respondToPermission(): Promise<void> {
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
requestId: "perm-order-1",
resolution: { behavior: "allow" },
});
this.modeId = "acceptEdits";
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
this.pending = [];
}
}
class OrderedPermissionClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new OrderedPermissionSession(config);
}
}
const manager = new AgentManager({
clients: {
codex: new OrderedPermissionClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000134",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const seen: string[] = [];
manager.subscribe((event) => {
if ("agentId" in event && event.agentId !== snapshot.id) {
return;
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`);
return;
}
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
seen.push(`resolved:${event.event.requestId}`);
}
});
await manager.respondToPermission(snapshot.id, "perm-order-1", {
behavior: "allow",
});
const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false");
const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1");
expect(refreshedStateIndex).toBeGreaterThanOrEqual(0);
expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex);
});
test("close during in-flight stream does not clear persistence sessionId", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -150,6 +150,11 @@ type ManagedAgentBase = {
features?: AgentFeature[];
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
bufferedPermissionResolutions: Map<
string,
Extract<AgentStreamEvent, { type: "permission_resolved" }>
>;
inFlightPermissionResponses: Set<string>;
pendingReplacement: boolean;
persistence: AgentPersistenceHandle | null;
historyPrimed: boolean;
@@ -1378,25 +1383,32 @@ export class AgentManager {
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
const agent = this.requireSessionAgent(agentId);
await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId);
const agent = this.requireAgent(agentId);
agent.inFlightPermissionResponses.add(requestId);
// Update currentModeId - the session may have changed mode internally
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
try {
agent.currentModeId = await agent.session.getCurrentMode();
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
modeId: agent.currentModeId,
};
}
} catch {
// Ignore errors from getCurrentMode - mode tracking is best effort
}
await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId);
this.emitState(agent);
try {
await this.refreshSessionState(agent);
} catch {
// Ignore refresh errors - state sync after permission approval is best effort.
}
this.touchUpdatedAt(agent);
await this.persistSnapshot(agent);
this.emitState(agent);
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
if (bufferedResolution) {
agent.bufferedPermissionResolutions.delete(requestId);
this.dispatchStream(agent.id, bufferedResolution);
}
} finally {
agent.inFlightPermissionResponses.delete(requestId);
agent.bufferedPermissionResolutions.delete(requestId);
}
}
async cancelAgentRun(agentId: string): Promise<boolean> {
@@ -1840,6 +1852,8 @@ export class AgentManager {
availableModes: [],
currentModeId: null,
pendingPermissions: new Map<string, AgentPermissionRequest>(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
activeForegroundTurnId: null,
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
@@ -2097,6 +2111,7 @@ export class AgentManager {
agent.pendingPermissions.clear();
}
this.syncFeaturesFromSession(agent);
await this.refreshRuntimeInfo(agent);
}
@@ -2174,6 +2189,7 @@ export class AgentManager {
}
let timelineRow: AgentTimelineRow | null = null;
let shouldDispatchEvent = true;
switch (event.type) {
case "thread_started":
@@ -2344,6 +2360,11 @@ export class AgentManager {
break;
case "permission_resolved":
agent.pendingPermissions.delete(event.requestId);
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
agent.bufferedPermissionResolutions.set(event.requestId, event);
shouldDispatchEvent = false;
break;
}
this.emitState(agent);
break;
default:
@@ -2355,7 +2376,7 @@ export class AgentManager {
}
// Skip dispatching individual stream events during history replay.
if (!options?.fromHistory) {
if (!options?.fromHistory && shouldDispatchEvent) {
this.dispatchStream(
agent.id,
event,
@@ -2436,9 +2457,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
if (agent.session?.features) {
agent.features = agent.session.features;
}
this.syncFeaturesFromSession(agent);
this.dispatch({
type: "agent_state",
@@ -2446,6 +2465,12 @@ export class AgentManager {
});
}
private syncFeaturesFromSession(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.features) {
agent.features = agent.session.features;
}
}
private checkAndSetAttention(agent: ManagedAgent): void {
const previousStatus = this.previousStatuses.get(agent.id);
const currentStatus = agent.lifecycle;
@@ -260,6 +260,34 @@ describe("toAgentPayload", () => {
expect(permissionA.title).toBe("Run command");
});
it("omits usage when any numeric usage field is NaN", () => {
const fields = [
"inputTokens",
"cachedInputTokens",
"outputTokens",
"totalCostUsd",
"contextWindowMaxTokens",
"contextWindowUsedTokens",
] as const;
for (const field of fields) {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 20,
totalCostUsd: 0.5,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 100_000,
[field]: Number.NaN,
},
});
const payload = toAgentPayload(agent);
expect(payload.lastUsage).toBeUndefined();
}
});
it("produces null title and current mode even without overrides", () => {
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
const payload = toAgentPayload(agent);
@@ -303,6 +331,56 @@ describe("toAgentPayload", () => {
expect(payload).not.toHaveProperty("lastUsage");
});
it("preserves context window usage fields when they are valid numbers", () => {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 42_000,
},
});
const payload = toAgentPayload(agent);
expect(payload.lastUsage).toEqual({
inputTokens: 10,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 42_000,
});
});
it("omits lastUsage when context window usage fields are invalid", () => {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
contextWindowMaxTokens: "200000" as unknown as number,
contextWindowUsedTokens: NaN,
},
});
const payload = toAgentPayload(agent);
expect(payload).not.toHaveProperty("lastUsage");
});
it("keeps existing lastUsage behavior when context window fields are absent", () => {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
outputTokens: 20,
totalCostUsd: 1.25,
},
});
const payload = toAgentPayload(agent);
expect(payload.lastUsage).toEqual({
inputTokens: 10,
outputTokens: 20,
totalCostUsd: 1.25,
});
});
it("includes features in the snapshot payload", () => {
const features = [createFeature()];
const agent = createManagedAgent({ features });
@@ -61,6 +61,7 @@ export function toStoredAgentRecord(
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
runtimeInfo,
features: agent.features,
persistence,
lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention,
@@ -167,6 +168,7 @@ function sanitizePendingPermissions(
...request,
input: sanitizeMetadata(request.input),
suggestions: sanitizeMetadataArray(request.suggestions),
actions: request.actions?.map((action) => ({ ...action })),
metadata: sanitizeMetadata(request.metadata),
}));
}
@@ -260,29 +262,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
}
const result: AgentUsage = {};
const inputTokens = sanitized.inputTokens;
if (typeof inputTokens === "number") {
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
result.inputTokens = inputTokens;
} else if (inputTokens !== undefined && inputTokens !== null) {
return undefined;
}
const cachedInputTokens = sanitized.cachedInputTokens;
if (typeof cachedInputTokens === "number") {
if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
result.cachedInputTokens = cachedInputTokens;
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
return undefined;
}
const outputTokens = sanitized.outputTokens;
if (typeof outputTokens === "number") {
if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
result.outputTokens = outputTokens;
} else if (outputTokens !== undefined && outputTokens !== null) {
return undefined;
}
const totalCostUsd = sanitized.totalCostUsd;
if (typeof totalCostUsd === "number") {
if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
result.totalCostUsd = totalCostUsd;
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
return undefined;
}
const contextWindowMaxTokens = sanitized.contextWindowMaxTokens;
if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) {
result.contextWindowMaxTokens = contextWindowMaxTokens;
} else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) {
return undefined;
}
const contextWindowUsedTokens = sanitized.contextWindowUsedTokens;
if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) {
result.contextWindowUsedTokens = contextWindowUsedTokens;
} else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) {
return undefined;
}
return Object.keys(result).length ? result : undefined;
}
@@ -132,6 +132,8 @@ export type AgentUsage = {
cachedInputTokens?: number;
outputTokens?: number;
totalCostUsd?: number;
contextWindowMaxTokens?: number;
contextWindowUsedTokens?: number;
};
export const TOOL_CALL_ICON_NAMES = [
@@ -329,6 +331,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
export type AgentPermissionUpdate = AgentMetadata;
export type AgentPermissionAction = {
id: string;
label: string;
behavior: "allow" | "deny";
variant?: "primary" | "secondary" | "danger";
intent?: "implement" | "implement_resume" | "dismiss";
};
export type AgentPermissionRequest = {
id: string;
provider: AgentProvider;
@@ -339,17 +349,20 @@ export type AgentPermissionRequest = {
input?: AgentMetadata;
detail?: ToolCallDetail;
suggestions?: AgentPermissionUpdate[];
actions?: AgentPermissionAction[];
metadata?: AgentMetadata;
};
export type AgentPermissionResponse =
| {
behavior: "allow";
selectedActionId?: string;
updatedInput?: AgentMetadata;
updatedPermissions?: AgentPermissionUpdate[];
}
| {
behavior: "deny";
selectedActionId?: string;
message?: string;
interrupt?: boolean;
};
@@ -4,7 +4,7 @@ import path from "node:path";
import { z } from "zod";
import type { Logger } from "pino";
import { AgentStatusSchema } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
@@ -57,6 +57,7 @@ const STORED_AGENT_SCHEMA = z.object({
extra: z.record(z.unknown()).optional(),
})
.optional(),
features: z.array(AgentFeatureSchema).optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
lastError: z.string().nullable().optional(),
requiresAttention: z.boolean().optional(),
@@ -236,6 +236,9 @@ function sanitizePermissionRequest(
if (sanitized.suggestions === undefined) {
delete sanitized.suggestions;
}
if (sanitized.actions === undefined) {
delete sanitized.actions;
}
if (sanitized.metadata === undefined) {
delete sanitized.metadata;
}
@@ -747,4 +747,55 @@ describe("ACPAgentSession", () => {
});
expect((session as any).activeForegroundTurnId).toBeNull();
});
test("auto-approves Copilot ACP permissions in autopilot mode without emitting prompt events", async () => {
const session = new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
},
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
const events: Array<{ type: string }> = [];
session.subscribe((event) => {
events.push(event as { type: string });
});
const response = await session.requestPermission({
toolCall: {
toolCallId: "tool-1",
title: "Edit file",
kind: "edit",
status: "pending",
} as any,
options: [
{ optionId: "allow-once", name: "Allow Once", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject Once", kind: "reject_once" },
],
} as any);
expect(response).toEqual({
outcome: {
outcome: "selected",
optionId: "allow-once",
},
});
expect(session.getPendingPermissions()).toEqual([]);
expect(events.find((event) => event.type === "permission_requested")).toBeUndefined();
});
});
@@ -107,6 +107,9 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true,
};
const COPILOT_AUTOPILOT_MODE =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
type ACPAgentClientOptions = {
provider: string;
logger: Logger;
@@ -1084,6 +1087,18 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
return selectedOption
? {
outcome: {
outcome: "selected",
optionId: selectedOption.optionId,
},
}
: { outcome: { outcome: "cancelled" } };
}
const requestId = randomUUID();
let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ??
@@ -1939,6 +1954,10 @@ function mapPermissionRequest(
};
}
function shouldAutoApprovePermissionRequest(provider: string, currentMode: string | null): boolean {
return provider === "copilot" && currentMode === COPILOT_AUTOPILOT_MODE;
}
function selectPermissionOption(
options: PermissionOption[],
response: AgentPermissionResponse,
@@ -835,6 +835,82 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
sdkQueryFactory.mockImplementation(() => queryMock);
const session = await createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.setMode("bypassPermissions");
await session.setMode("plan");
const internal = session as unknown as {
handlePermissionRequest: (
toolName: string,
input: Record<string, unknown>,
options: Record<string, unknown>,
) => Promise<unknown>;
};
const pendingResolution = internal.handlePermissionRequest(
"ExitPlanMode",
{ plan: "- Implement the approved plan" },
{},
);
const requestEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(requestEvent).toBeDefined();
expect(requestEvent?.request.actions).toEqual([
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
{
id: "implement_resume",
label: "Implement with Bypass",
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
},
]);
if (!requestEvent) {
throw new Error("Expected plan permission request");
}
await session.respondToPermission(requestEvent.request.id, {
behavior: "allow",
selectedActionId: "implement_resume",
});
await expect(pendingResolution).resolves.toMatchObject({
behavior: "allow",
updatedInput: { plan: "- Implement the approved plan" },
});
expect(queryMock.setPermissionMode).toHaveBeenLastCalledWith("bypassPermissions");
expect(await session.getCurrentMode()).toBe("bypassPermissions");
} finally {
await session.close();
}
});
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession();
const internal = session as unknown as {
@@ -112,6 +112,28 @@ describe("convertClaudeHistoryEntry", () => {
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips meta user entries from Claude skill loading", () => {
const entry = {
type: "user",
isMeta: true,
message: {
role: "user",
content: [
{
type: "text",
text: "Base directory for this skill: /tmp/skill\n\n# Orchestrate\n\nYou are an end-to-end implementation orchestrator.",
},
],
},
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips interrupt placeholder transcript noise", () => {
const interruptEntry = {
type: "user",
@@ -261,3 +283,356 @@ describe("ClaudeAgentClient.listModels", () => {
expect(defaultModel?.id).toBe("claude-opus-4-6");
});
});
describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
async function createSessionForTest(): Promise<any> {
const client = new ClaudeAgentClient({ logger });
return client.createSession({
provider: "claude",
cwd: process.cwd(),
});
}
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const queuedMessages: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
let turnIndex = 0;
let closed = false;
function wakeNextWaiter() {
const waiter = waiters.shift();
waiter?.();
}
function enqueue(message: Record<string, unknown>) {
queuedMessages.push(message);
wakeNextWaiter();
}
void (async () => {
for await (const _prompt of prompt) {
const turnMessages = turns[turnIndex] ?? [];
turnIndex += 1;
for (const message of turnMessages) {
enqueue(message);
}
}
closed = true;
wakeNextWaiter();
})();
return {
next: vi.fn(async () => {
while (queuedMessages.length === 0 && !closed) {
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
}
if (queuedMessages.length === 0) {
return { done: true, value: undefined };
}
return { done: false, value: queuedMessages.shift() };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => {
closed = true;
wakeNextWaiter();
return undefined;
}),
close: vi.fn(() => {
closed = true;
wakeNextWaiter();
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
});
}
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage(
{
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
},
{
"claude-sonnet-4-6": { contextWindow: 200_000 },
"claude-opus-4-6": { contextWindow: 1_000_000 },
},
);
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowMaxTokens: 1_000_000,
contextWindowUsedTokens: 22,
});
});
test("contextWindowUsedTokens is derived from result usage when task_progress is missing", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 25,
});
});
test("contextWindowUsedTokens is populated from task_progress usage data", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
});
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-1",
session_id: "session-1",
});
expect(events).toContainEqual({
type: "turn_completed",
provider: "claude",
usage: {
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 999,
},
});
});
test("task_progress usage takes priority over derived result usage", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
});
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 999,
});
});
test("contextWindowUsedTokens resets between turns and falls back to result usage", async () => {
const queryFactory = createQueryFactoryForTurns([
[
{
type: "system",
subtype: "init",
session_id: "session-1",
permissionMode: "default",
model: "claude-sonnet-4-6",
},
{
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
},
{
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-1",
session_id: "session-1",
},
],
[
{
type: "result",
subtype: "success",
duration_ms: 110,
duration_api_ms: 80,
is_error: false,
num_turns: 1,
result: "still done",
stop_reason: null,
total_cost_usd: 0.1,
usage: {
input_tokens: 11,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 6,
output_tokens: 8,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-2",
session_id: "session-1",
},
],
]);
const client = new ClaudeAgentClient({ logger, queryFactory });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const firstTurn = await session.run("turn 1");
const secondTurn = await session.run("turn 2");
expect(firstTurn.usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 999,
});
expect(secondTurn.usage).toEqual({
inputTokens: 11,
cachedInputTokens: 6,
outputTokens: 8,
totalCostUsd: 0.1,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 28,
});
} finally {
await session.close();
}
});
test("convertUsage derives used tokens even when modelUsage is missing", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 22,
});
});
});
@@ -18,6 +18,7 @@ import {
type SpawnOptions,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKTaskProgressMessage,
type SDKResultMessage,
type SDKSystemMessage,
type SDKUserMessage,
@@ -47,6 +48,7 @@ import {
} from "./diagnostic-utils.js";
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
@@ -669,6 +671,43 @@ function resolvePermissionKind(
return "tool";
}
function getClaudeModeLabel(modeId: PermissionMode): string {
return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId;
}
function buildClaudePlanPermissionActions(
resumeMode: PermissionMode | null,
): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
];
if (resumeMode === "bypassPermissions") {
actions.push({
id: "implement_resume",
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
});
}
return actions;
}
type TimelineFragment = {
kind: "assistant" | "reasoning";
text: string;
@@ -967,7 +1006,8 @@ function isSyntheticUserEntry(entry: unknown): boolean {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { isSynthetic?: unknown }).isSynthetic === true;
const candidate = entry as { isSynthetic?: unknown; isMeta?: unknown };
return candidate.isSynthetic === true || candidate.isMeta === true;
}
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
@@ -1160,6 +1200,40 @@ function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string
}
}
function extractContextWindowSize(modelUsage: unknown): number | undefined {
if (!modelUsage || typeof modelUsage !== "object") {
return undefined;
}
let maxContextWindow: number | undefined;
for (const value of Object.values(modelUsage as Record<string, unknown>)) {
if (!value || typeof value !== "object") {
continue;
}
const contextWindow = (value as { contextWindow?: unknown }).contextWindow;
if (
typeof contextWindow !== "number" ||
!Number.isFinite(contextWindow) ||
contextWindow <= 0
) {
continue;
}
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
}
return maxContextWindow;
}
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
): number | undefined {
const totalTokens = message.usage?.total_tokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined;
}
return totalTokens;
}
class ClaudeAgentSession implements AgentSession {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1175,6 +1249,7 @@ class ClaudeAgentSession implements AgentSession {
private claudeSessionId: string | null;
private persistence: AgentPersistenceHandle | null;
private currentMode: PermissionMode;
private planResumeMode: PermissionMode | null = null;
private availableModes: AgentMode[] = DEFAULT_MODES;
private toolUseCache = new Map<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
@@ -1201,6 +1276,7 @@ class ClaudeAgentSession implements AgentSession {
private pendingInterruptAbort = false;
private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false;
private lastContextWindowUsedTokens: number | undefined;
private userMessageIds: string[] = [];
private recentStderr = "";
private closed = false;
@@ -1235,6 +1311,9 @@ class ClaudeAgentSession implements AgentSession {
}
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
}
get id(): string | null {
@@ -1380,6 +1459,7 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt);
this.lastForegroundPromptText = this.extractPromptText(prompt);
this.lastContextWindowUsedTokens = undefined;
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
@@ -1476,8 +1556,16 @@ class ClaudeAgentSession implements AgentSession {
}
const normalized = isPermissionMode(modeId) ? modeId : "default";
const previousMode = this.currentMode;
const query = await this.ensureQuery();
await query.setPermissionMode(normalized);
if (normalized === "plan") {
if (previousMode !== "plan") {
this.planResumeMode = previousMode;
}
} else {
this.planResumeMode = normalized;
}
this.currentMode = normalized;
}
@@ -1524,13 +1612,22 @@ class ClaudeAgentSession implements AgentSession {
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
await this.setMode("acceptEdits");
const selectedActionId = response.selectedActionId;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
input: pending.request.input ?? null,
output: { approved: true },
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
@@ -1847,6 +1944,9 @@ class ClaudeAgentSession implements AgentSession {
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Reset session identity for explicit restarts so the new query starts
// a fresh session rather than resuming the previous one.
this.claudeSessionId = null;
oldInput?.end();
oldQuery.close?.();
try {
@@ -1856,6 +1956,12 @@ class ClaudeAgentSession implements AgentSession {
}
}
// When the pump died unexpectedly (query became null, e.g. after a session
// ID overwrite error), preserve claudeSessionId so buildOptions() passes
// resume: sessionId and the new query auto-resumes the previous session.
// For explicit restarts above, claudeSessionId was already cleared.
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
@@ -2197,6 +2303,7 @@ class ClaudeAgentSession implements AgentSession {
this.notifySubscribers(event);
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
}
@@ -2212,10 +2319,12 @@ class ClaudeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn terminal");
}
}
@@ -2228,6 +2337,7 @@ class ClaudeAgentSession implements AgentSession {
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
this.lastContextWindowUsedTokens = undefined;
this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started");
}
@@ -2238,6 +2348,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn completed");
}
@@ -2566,6 +2677,10 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude",
});
}
} else if (message.subtype === "task_progress") {
this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ??
this.lastContextWindowUsedTokens;
}
break;
case "user": {
@@ -2643,7 +2758,7 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "result": {
const usage = this.convertUsage(message);
const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") {
events.push({ type: "turn_completed", provider: "claude", usage });
} else {
@@ -2688,11 +2803,16 @@ class ClaudeAgentSession implements AgentSession {
if (this.claudeSessionId === sessionId) {
return null;
}
throw new Error(
`CRITICAL: Claude session ID overwrite detected! ` +
`Existing: ${this.claudeSessionId}, New: ${sessionId}. ` +
`This indicates a session identity corruption bug.`,
// Session ID changed mid-stream (e.g. a hook caused Claude to restart
// with a new session). Accept the new ID and continue — the turn should
// not be failed just because the underlying subprocess cycled.
this.logger.warn(
{ existingSessionId: this.claudeSessionId, newSessionId: sessionId },
"Claude session ID changed in message; accepting new session",
);
this.claudeSessionId = sessionId;
this.persistence = null;
return sessionId;
}
private handleSystemMessage(message: SDKSystemMessage): string | null {
@@ -2727,14 +2847,20 @@ class ClaudeAgentSession implements AgentSession {
} else if (existingSessionId === newSessionId) {
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
} else {
throw new Error(
`CRITICAL: Claude session ID overwrite detected! ` +
`Existing: ${existingSessionId}, New: ${newSessionId}. ` +
`This indicates a session identity corruption bug.`,
// Session ID changed in an init message (e.g. a hook restarted Claude
// with a new session mid-turn). Accept the new ID and continue.
this.logger.warn(
{ existingSessionId, newSessionId },
"Claude session ID changed in init message; accepting new session",
);
this.claudeSessionId = newSessionId;
threadStartedSessionId = newSessionId;
}
this.availableModes = DEFAULT_MODES;
this.currentMode = message.permissionMode;
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
this.persistence = null;
if (message.model) {
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
@@ -2776,16 +2902,38 @@ class ClaudeAgentSession implements AgentSession {
return null;
}
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
if (!message.usage) {
return undefined;
}
return {
const usage: AgentUsage = {
inputTokens: message.usage.input_tokens,
cachedInputTokens: message.usage.cache_read_input_tokens,
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
const contextWindowMaxTokens = extractContextWindowSize(
modelUsage ?? message.modelUsage,
);
if (contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = contextWindowMaxTokens;
}
if (typeof this.lastContextWindowUsedTokens === "number") {
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (message.usage) {
const usageWithCacheCreation = message.usage as typeof message.usage & {
cache_creation_input_tokens?: number;
};
const derived =
(message.usage.input_tokens ?? 0) +
(usageWithCacheCreation.cache_creation_input_tokens ?? 0) +
(message.usage.cache_read_input_tokens ?? 0) +
(message.usage.output_tokens ?? 0);
if (Number.isFinite(derived) && derived > 0) {
usage.contextWindowUsedTokens = derived;
}
}
return usage;
}
private handlePermissionRequest: CanUseTool = async (
@@ -2820,6 +2968,8 @@ class ClaudeAgentSession implements AgentSession {
input,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
actions:
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
metadata: Object.keys(metadata).length ? metadata : undefined,
};
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { existsSync, rmSync } from "node:fs";
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
@@ -40,6 +40,183 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
describe("Codex app-server provider", () => {
const logger = createTestLogger();
test("extracts context window usage from snake_case token payloads", () => {
expect(
__codexAppServerInternals.toAgentUsage({
model_context_window: 200000,
last: {
total_tokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
});
});
test("extracts context window usage from camelCase token payloads", () => {
expect(
__codexAppServerInternals.toAgentUsage({
modelContextWindow: 200000,
last: {
totalTokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
});
});
test("keeps existing usage behavior when context window fields are missing", () => {
expect(
__codexAppServerInternals.toAgentUsage({
last: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
});
});
test("excludes invalid context window values", () => {
expect(
__codexAppServerInternals.toAgentUsage({
model_context_window: Number.NaN,
modelContextWindow: "200000",
last: {
total_tokens: Number.NaN,
totalTokens: "50000",
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
});
});
test("normalizes raw output schemas for Codex structured outputs", () => {
const input = {
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity"],
},
},
overall: { type: "string" },
},
required: ["overall"],
};
const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input);
expect(normalized).toEqual({
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity", "summary"],
additionalProperties: false,
},
},
overall: { type: "string" },
},
required: ["overall", "findings"],
additionalProperties: false,
});
expect(input).toEqual({
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity"],
},
},
overall: { type: "string" },
},
required: ["overall"],
});
});
test("passes a normalized output schema to turn/start", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = { request } as any;
await session.startTurn("Return JSON", {
outputSchema: {
type: "object",
properties: {
summary: { type: "string" },
},
},
});
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
outputSchema: {
type: "object",
properties: {
summary: { type: "string" },
},
required: ["summary"],
additionalProperties: false,
},
}),
);
});
test("maps image prompt blocks to Codex localImage input", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
@@ -345,4 +522,165 @@ describe("Codex app-server provider", () => {
},
});
});
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-1" },
});
(session as any).handleNotification("turn/plan/updated", {
plan: [
{ step: "Inspect the existing auth flow", status: "completed" },
{ step: "Implement the button behavior", status: "pending" },
],
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events.at(-2)).toEqual({
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
input: {
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
},
actions: [
expect.objectContaining({
id: "reject",
label: "Reject",
behavior: "deny",
}),
expect.objectContaining({
id: "implement",
label: "Implement",
behavior: "allow",
}),
],
}),
});
expect(events.at(-1)).toEqual({
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: undefined,
});
});
test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const startTurnSpy = vi
.spyOn(session, "startTurn")
.mockResolvedValue({ turnId: "follow-up-turn" });
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-2" },
});
(session as any).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the new flow", status: "pending" }],
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
}
await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect((session as any).serviceTier).toBeNull();
expect((session as any).planModeEnabled).toBe(false);
expect((session as any).config.featureValues).toEqual({
plan_mode: false,
fast_mode: false,
});
expect(startTurnSpy).toHaveBeenCalledWith(
expect.stringContaining("The user approved the plan. Implement it now."),
);
expect(events.at(-1)).toEqual({
type: "permission_resolved",
provider: "codex",
requestId: request.request.id,
resolution: {
behavior: "allow",
selectedActionId: "implement",
},
});
});
test("failed synthetic Codex plan implementation keeps the permission pending for retry", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const startTurnSpy = vi
.spyOn(session, "startTurn")
.mockRejectedValueOnce(new Error("follow-up failed"));
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-retry" },
});
(session as any).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the retriable flow", status: "pending" }],
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
}
await expect(
session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
}),
).rejects.toThrow("follow-up failed");
expect(startTurnSpy).toHaveBeenCalledTimes(1);
expect((session as any).planModeEnabled).toBe(true);
expect((session as any).config.featureValues).toEqual({
plan_mode: true,
fast_mode: true,
});
expect(session.getPendingPermissions()).toEqual([request.request]);
expect(
events.some(
(event) =>
event.type === "permission_resolved" && event.requestId === request.request.id,
),
).toBe(false);
});
});
@@ -1,4 +1,5 @@
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentFeature,
@@ -64,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000;
const CODEX_PROVIDER = "codex" as const;
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -143,6 +146,73 @@ function normalizeCodexModelLabel(displayName: string): string {
return displayName.replace(/\bgpt\b/gi, "GPT");
}
function isSchemaRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
const type = schema.type;
return (
isSchemaRecord(schema.properties) ||
type === "object" ||
(Array.isArray(type) && type.includes("object"))
);
}
function normalizeCodexOutputSchemaNode(
schema: unknown,
path: string,
): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`));
}
if (!isSchemaRecord(schema)) {
return schema;
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
normalized[key] = normalizeCodexOutputSchemaNode(value, `${path}.${key}`);
}
if (!isObjectSchemaNode(normalized)) {
return normalized;
}
if (normalized.additionalProperties === undefined) {
normalized.additionalProperties = false;
} else if (normalized.additionalProperties !== false) {
throw new Error(
`Codex structured outputs require ${path} to set additionalProperties to false for object schemas.`,
);
}
const properties = isSchemaRecord(normalized.properties) ? normalized.properties : null;
if (!properties) {
return normalized;
}
const propertyKeys = Object.keys(properties);
const existingRequired = Array.isArray(normalized.required)
? normalized.required.filter((entry): entry is string => typeof entry === "string")
: [];
normalized.required = Array.from(new Set([...existingRequired, ...propertyKeys]));
return normalized;
}
function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
if (!isSchemaRecord(schema)) {
throw new Error("Codex structured outputs require a JSON object schema.");
}
const normalized = normalizeCodexOutputSchemaNode(schema, "$");
if (!isSchemaRecord(normalized) || !isObjectSchemaNode(normalized)) {
throw new Error("Codex structured outputs require a root object schema.");
}
return normalized;
}
type CodexConfiguredDefaults = {
model?: string;
thinkingOptionId?: string;
@@ -677,12 +747,42 @@ function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
const usage = tokenUsage as {
last?: { inputTokens?: number; cachedInputTokens?: number; outputTokens?: number };
model_context_window?: number;
modelContextWindow?: number;
last?: {
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
total_tokens?: number;
totalTokens?: number;
};
};
const contextWindowMaxTokens =
typeof usage.model_context_window === "number" &&
Number.isFinite(usage.model_context_window) &&
usage.model_context_window > 0
? usage.model_context_window
: typeof usage.modelContextWindow === "number" &&
Number.isFinite(usage.modelContextWindow) &&
usage.modelContextWindow > 0
? usage.modelContextWindow
: undefined;
const contextWindowUsedTokens =
typeof usage.last?.total_tokens === "number" &&
Number.isFinite(usage.last.total_tokens) &&
usage.last.total_tokens > 0
? usage.last.total_tokens
: typeof usage.last?.totalTokens === "number" &&
Number.isFinite(usage.last.totalTokens) &&
usage.last.totalTokens > 0
? usage.last.totalTokens
: undefined;
return {
inputTokens: usage.last?.inputTokens,
cachedInputTokens: usage.last?.cachedInputTokens,
outputTokens: usage.last?.outputTokens,
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
};
}
@@ -739,6 +839,53 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
};
}
function buildPlanPermissionActions(
options?: { includeResumeAction?: boolean; resumeLabel?: string },
): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
];
if (options?.includeResumeAction && options.resumeLabel) {
actions.push({
id: "implement_resume",
label: options.resumeLabel,
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
});
}
return actions;
}
function buildCodexPlanImplementationPrompt(planText: string): string {
const normalizedPlan = normalizePlanMarkdown(planText);
if (!normalizedPlan) {
return `${CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX} Make the required code changes and verify them.`;
}
return [
CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX,
"Approved plan:",
normalizedPlan,
"Carry out the work, make the necessary code changes, and verify the result.",
].join("\n\n");
}
type CodexQuestionOption = {
label: string;
description?: string;
@@ -2269,8 +2416,9 @@ class CodexAppServerAgentSession implements AgentSession {
string,
{
resolve: (value: unknown) => void;
kind: "command" | "file" | "question";
kind: "command" | "file" | "question" | "plan";
questions?: CodexQuestionPrompt[];
planText?: string;
}
>();
private resolvedPermissionRequests = new Set<string>();
@@ -2289,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestUsage: AgentUsage | undefined;
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private connected = false;
private collaborationModes: Array<{
name: string;
@@ -2472,6 +2621,79 @@ class CodexAppServerAgentSession implements AgentSession {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
this.config.featureValues = {
...(this.config.featureValues ?? {}),
[featureId]: value,
};
if (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
return;
}
this.planModeEnabled = value;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
private rememberPlanResult(item: ToolCallTimelineItem): void {
if (item.detail.type !== "plan") {
return;
}
this.latestPlanResult = {
callId: item.callId,
text: item.detail.text,
turnId: this.currentTurnId,
};
}
private emitSyntheticPlanApprovalRequest(planText: string): void {
const requestId = `permission-${randomUUID()}`;
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
description: "Review the proposed plan before implementation starts.",
input: { plan: planText },
actions: buildPlanPermissionActions(),
metadata: {
planText,
source: "codex_plan_approval",
},
};
this.pendingPermissions.set(requestId, request);
this.pendingPermissionHandlers.set(requestId, {
resolve: () => undefined,
kind: "plan",
planText,
});
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
private async handleApprovedPlanPermission(params: { planText?: unknown }): Promise<void> {
const planText =
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
const previousPlanMode = this.planModeEnabled;
const previousFastMode = this.serviceTier === "fast";
this.applyFeatureValue("plan_mode", false);
this.applyFeatureValue("fast_mode", false);
try {
await this.startTurn(buildCodexPlanImplementationPrompt(planText));
} catch (error) {
this.applyFeatureValue("plan_mode", previousPlanMode);
this.applyFeatureValue("fast_mode", previousFastMode);
throw error;
}
}
private registerRequestHandlers(): void {
if (!this.client) return;
@@ -2774,7 +2996,7 @@ class CodexAppServerAgentSession implements AgentSession {
params.cwd = this.config.cwd;
}
if (options?.outputSchema) {
params.outputSchema = options.outputSchema;
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
}
if (this.config.systemPrompt?.trim()) {
params.developerInstructions = this.config.systemPrompt.trim();
@@ -2869,14 +3091,11 @@ class CodexAppServerAgentSession implements AgentSession {
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
this.applyFeatureValue("fast_mode", Boolean(value));
return;
}
if (featureId === "plan_mode") {
this.planModeEnabled = Boolean(value);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
this.applyFeatureValue("plan_mode", Boolean(value));
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
@@ -2892,6 +3111,26 @@ class CodexAppServerAgentSession implements AgentSession {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
}
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
if (pending.kind === "plan") {
if (response.behavior === "allow") {
await this.handleApprovedPlanPermission({
planText: pending.planText ?? pendingRequest?.metadata?.planText,
});
}
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: response,
});
return;
}
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
@@ -3193,6 +3432,7 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId;
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedExecCommandStartedCallIds.clear();
@@ -3214,6 +3454,9 @@ class CodexAppServerAgentSession implements AgentSession {
} else if (parsed.status === "interrupted") {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
}
this.emitEvent({
type: "turn_completed",
provider: CODEX_PROVIDER,
@@ -3221,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession {
});
}
this.activeForegroundTurnId = null;
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedExecCommandStartedCallIds.clear();
@@ -3242,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession {
),
});
if (timelineItem) {
this.rememberPlanResult(timelineItem);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
@@ -3435,6 +3680,9 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
if (timelineItem.type === "tool_call") {
if (timelineItem.detail.type === "plan") {
this.rememberPlanResult(timelineItem);
}
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
@@ -4008,6 +4256,8 @@ export const __codexAppServerInternals = {
mapCodexPatchNotificationToToolCall,
planStepsToMarkdown,
mapCodexPlanToToolCall,
normalizeCodexOutputSchema,
normalizeCodexQuestionPrompts,
toAgentUsage,
threadItemToTimeline,
};
@@ -12,7 +12,11 @@ import path from "node:path";
import { execFileSync } from "node:child_process";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
__openCodeInternals,
OpenCodeAgentClient,
translateOpenCodeEvent,
} from "./opencode-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type {
AgentSessionConfig,
@@ -202,6 +206,11 @@ const hasOpenCode = isBinaryInstalled("opencode");
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
expect(model.id).toContain("/");
expect(model.metadata).toMatchObject({
providerId: expect.any(String),
modelId: expect.any(String),
contextWindowMaxTokens: expect.any(Number),
});
}
}, 60_000);
@@ -303,3 +312,149 @@ const hasOpenCode = isBinaryInstalled("opencode");
rmSync(cwd, { recursive: true, force: true });
}, 180_000);
});
describe("OpenCode adapter context-window normalization", () => {
test("preserves provider catalog context limit in model metadata", () => {
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
{ id: "openai", name: "OpenAI" },
"gpt-5",
{
name: "GPT-5",
family: "gpt",
limit: {
context: 400_000,
input: 200_000,
output: 16_384,
},
},
);
expect(definition.metadata).toMatchObject({
providerId: "openai",
modelId: "gpt-5",
contextWindowMaxTokens: 400_000,
limit: {
context: 400_000,
input: 200_000,
output: 16_384,
},
});
});
test("resolves selected model context window from connected provider catalog data", () => {
expect(
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
{
connected: ["openai"],
all: [
{
id: "openai",
models: {
"gpt-5": {
limit: {
context: 400_000,
output: 16_384,
},
},
},
},
{
id: "anthropic",
models: {
"claude-opus": {
limit: {
context: 1_000_000,
output: 8_192,
},
},
},
},
],
},
"openai/gpt-5",
),
).toBe(400_000);
expect(
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
{
connected: ["openai"],
all: [
{
id: "anthropic",
models: {
"claude-opus": {
limit: {
context: 1_000_000,
output: 8_192,
},
},
},
},
],
},
"anthropic/claude-opus",
),
).toBeUndefined();
});
test("normalizes step-finish usage into AgentUsage context window fields", () => {
const usage = { contextWindowMaxTokens: 400_000 };
__openCodeInternals.mergeOpenCodeStepFinishUsage(usage, {
cost: 0.25,
tokens: {
total: 999_999,
input: 30_000,
output: 12_000,
reasoning: 10_000,
cache: {
read: 2_000,
write: 1_000,
},
},
});
expect(usage).toEqual({
contextWindowMaxTokens: 400_000,
contextWindowUsedTokens: 55_000,
cachedInputTokens: 2_000,
inputTokens: 30_000,
outputTokens: 12_000,
totalCostUsd: 0.25,
});
expect(__openCodeInternals.hasNormalizedOpenCodeUsage(usage)).toBe(true);
});
test("resolves context window max tokens from assistant message metadata", () => {
const usage = {};
const onAssistantModelContextWindowResolved = vi.fn();
translateOpenCodeEvent(
{
type: "message.updated",
properties: {
info: {
id: "message-1",
sessionID: "session-1",
role: "assistant",
providerID: "openai",
modelID: "gpt-5",
},
},
},
{
sessionId: "session-1",
messageRoles: new Map(),
accumulatedUsage: usage,
streamedPartKeys: new Set(),
emittedStructuredMessageIds: new Set(),
partTypes: new Map(),
modelContextWindowsByModelKey: new Map([["openai/gpt-5", 400_000]]),
onAssistantModelContextWindowResolved,
},
);
expect(onAssistantModelContextWindowResolved).toHaveBeenCalledWith(400_000);
});
});
@@ -1,6 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
import {
createOpencodeClient,
type OpencodeClient,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
import { z } from "zod";
@@ -268,6 +271,224 @@ function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
});
}
function readPositiveFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function buildOpenCodeModelLookupKey(providerId: string, modelId: string): string {
return `${providerId}/${modelId}`;
}
function parseOpenCodeModelLookupKey(modelId: string | null | undefined): string | undefined {
if (typeof modelId !== "string" || modelId.trim().length === 0) {
return undefined;
}
const slashIndex = modelId.indexOf("/");
if (slashIndex <= 0 || slashIndex === modelId.length - 1) {
return undefined;
}
const providerId = modelId.slice(0, slashIndex).trim();
const providerModelId = modelId.slice(slashIndex + 1).trim();
if (!providerId || !providerModelId) {
return undefined;
}
return buildOpenCodeModelLookupKey(providerId, providerModelId);
}
function extractOpenCodeModelContextWindow(model: unknown): number | undefined {
if (!model || typeof model !== "object") {
return undefined;
}
const limit = (model as { limit?: { context?: unknown } }).limit;
return readPositiveFiniteNumber(limit?.context);
}
function buildOpenCodeModelDefinition(
provider: {
id: string;
name: string;
},
modelId: string,
model: {
name: string;
family?: string;
release_date?: string;
attachment?: boolean;
reasoning?: boolean;
tool_call?: boolean;
cost?: unknown;
limit?: { context?: number; input?: number; output?: number };
variants?: Record<string, unknown>;
},
): AgentModelDefinition {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
return {
provider: "opencode",
id: `${provider.id}/${modelId}`,
label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(),
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId: thinkingOptions[0]?.id,
metadata: {
providerId: provider.id,
providerName: provider.name,
modelId,
family: model.family,
releaseDate: model.release_date,
supportsAttachments: model.attachment,
supportsReasoning: model.reasoning,
supportsToolCall: model.tool_call,
cost: model.cost,
contextWindowMaxTokens: extractOpenCodeModelContextWindow(model),
...(model.limit ? { limit: model.limit } : {}),
},
};
}
function resolveOpenCodeSelectedModelContextWindow(
providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined,
modelId: string | null | undefined,
): number | undefined {
if (!providers) {
return undefined;
}
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
if (!modelLookupKey) {
return undefined;
}
const lookup = buildOpenCodeModelContextWindowLookup(providers);
return lookup.get(modelLookupKey);
}
function buildOpenCodeModelContextWindowLookup(providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined): Map<string, number> {
const lookup = new Map<string, number>();
if (!providers) {
return lookup;
}
const connectedProviderIds = new Set(providers.connected ?? []);
for (const provider of providers.all ?? []) {
if (!connectedProviderIds.has(provider.id)) {
continue;
}
for (const [modelId, modelDefinition] of Object.entries(provider.models ?? {})) {
const contextWindow = extractOpenCodeModelContextWindow(modelDefinition);
if (contextWindow === undefined) {
continue;
}
lookup.set(buildOpenCodeModelLookupKey(provider.id, modelId), contextWindow);
}
}
return lookup;
}
function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(
info: AgentMetadata | undefined,
): string | undefined {
if (!info || info.role !== "assistant") {
return undefined;
}
const providerId = readNonEmptyString(info.providerID);
const modelId = readNonEmptyString(info.modelID);
if (!providerId || !modelId) {
return undefined;
}
return buildOpenCodeModelLookupKey(providerId, modelId);
}
function mergeOpenCodeStepFinishUsage(
usage: AgentUsage,
part: {
cost?: unknown;
tokens?: {
input?: unknown;
output?: unknown;
reasoning?: unknown;
total?: unknown;
cache?: {
read?: unknown;
write?: unknown;
};
};
},
): void {
const inputTokens = readPositiveFiniteNumber(part.tokens?.input);
const outputTokens = readPositiveFiniteNumber(part.tokens?.output);
const reasoningTokens = readPositiveFiniteNumber(part.tokens?.reasoning);
const cacheReadTokens = readPositiveFiniteNumber(part.tokens?.cache?.read);
const cacheWriteTokens = readPositiveFiniteNumber(part.tokens?.cache?.write);
const totalTokens =
(inputTokens ?? 0) +
(outputTokens ?? 0) +
(reasoningTokens ?? 0) +
(cacheReadTokens ?? 0) +
(cacheWriteTokens ?? 0);
const cost = readPositiveFiniteNumber(part.cost);
if (inputTokens !== undefined) {
usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens;
}
if (cacheReadTokens !== undefined) {
usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens;
}
if (outputTokens !== undefined) {
usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens;
}
if (totalTokens > 0) {
usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens;
}
if (cost !== undefined) {
usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
}
}
function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
return [
usage.inputTokens,
usage.cachedInputTokens,
usage.outputTokens,
usage.totalCostUsd,
usage.contextWindowMaxTokens,
usage.contextWindowUsedTokens,
].some((value) => typeof value === "number" && Number.isFinite(value));
}
export const __openCodeInternals = {
buildOpenCodeModelContextWindowLookup,
buildOpenCodeModelDefinition,
buildOpenCodeModelLookupKey,
extractOpenCodeModelContextWindow,
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo,
resolveOpenCodeSelectedModelContextWindow,
};
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
@@ -423,6 +644,7 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly serverManager: OpenCodeServerManager;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
@@ -460,7 +682,15 @@ export class OpenCodeAgentClient implements AgentClient {
throw new Error("OpenCode session creation returned no data");
}
return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger);
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
return new OpenCodeAgentSession(
openCodeConfig,
client,
session.id,
this.logger,
new Map(this.modelContextWindows),
);
}
async resumeSession(
@@ -485,7 +715,15 @@ export class OpenCodeAgentClient implements AgentClient {
directory: openCodeConfig.cwd,
});
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger);
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
return new OpenCodeAgentSession(
openCodeConfig,
client,
handle.sessionId,
this.logger,
new Map(this.modelContextWindows),
);
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
@@ -533,6 +771,7 @@ export class OpenCodeAgentClient implements AgentClient {
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) {
// Skip providers that aren't connected/configured
if (!connectedProviderIds.has(provider.id)) {
@@ -540,32 +779,15 @@ export class OpenCodeAgentClient implements AgentClient {
}
for (const [modelId, model] of Object.entries(provider.models)) {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
models.push({
provider: "opencode",
id: `${provider.id}/${modelId}`,
label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(),
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId: thinkingOptions[0]?.id,
metadata: {
providerId: provider.id,
providerName: provider.name,
modelId,
family: model.family,
releaseDate: model.release_date,
supportsAttachments: model.attachment,
supportsReasoning: model.reasoning,
supportsToolCall: model.tool_call,
cost: model.cost,
},
});
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
if (contextWindowMaxTokens !== undefined) {
this.modelContextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId),
contextWindowMaxTokens,
);
}
models.push(definition);
}
}
@@ -685,6 +907,22 @@ export class OpenCodeAgentClient implements AgentClient {
}
return { ...config, provider: "opencode" };
}
private async populateModelContextWindowCache(
client: OpencodeClient,
cwd: string,
): Promise<void> {
const response = await client.provider.list({ directory: cwd });
if (response.error || !response.data) {
return;
}
const lookup = buildOpenCodeModelContextWindowLookup(response.data);
this.modelContextWindows.clear();
for (const [modelLookupKey, contextWindowMaxTokens] of lookup.entries()) {
this.modelContextWindows.set(modelLookupKey, contextWindowMaxTokens);
}
}
}
export type OpenCodeEventTranslationState = {
@@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = {
emittedStructuredMessageIds: Set<string>;
/** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>;
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
};
function stringifyStructuredAssistantMessage(value: unknown): string | null {
@@ -787,6 +1027,15 @@ export function translateOpenCodeEvent(
if (messageId && messageSessionId === state.sessionId && role) {
state.messageRoles.set(messageId, role);
if (role === "assistant") {
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info);
if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
if (contextWindowMaxTokens !== undefined) {
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
}
}
}
if (
role === "assistant" &&
!state.emittedStructuredMessageIds.has(messageId) &&
@@ -894,20 +1143,7 @@ export function translateOpenCodeEvent(
});
}
} else if (partType === "step-finish") {
const tokens = part.tokens as
| { input?: number; output?: number; reasoning?: number }
| undefined;
const cost = part.cost as number | undefined;
if (tokens) {
state.accumulatedUsage.inputTokens =
(state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
state.accumulatedUsage.outputTokens =
(state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
}
if (cost !== undefined) {
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
}
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
}
break;
}
@@ -1083,6 +1319,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly client: OpencodeClient;
private readonly sessionId: string;
private readonly logger: Logger;
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null;
@@ -1102,18 +1339,23 @@ class OpenCodeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
}
get id(): string | null {
@@ -1133,6 +1375,8 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -1246,6 +1490,9 @@ class OpenCodeAgentSession implements AgentSession {
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model);
@@ -1781,28 +2028,35 @@ class OpenCodeAgentSession implements AgentSession {
streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
partTypes: this.partTypes,
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
if (!this.config.model) {
this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens;
}
},
});
for (const translatedEvent of translated) {
if (translatedEvent.type === "permission_requested") {
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
}
if (translatedEvent.type === "turn_completed") {
translatedEvent.usage = this.extractAndResetUsage();
}
}
return translated;
}
private extractAndResetUsage(): AgentUsage | undefined {
const usage = this.accumulatedUsage;
this.accumulatedUsage = {};
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
return this.selectedModelContextWindowMaxTokens;
}
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) {
private resolveConfiguredModelContextWindowMaxTokens(
modelId: string | undefined,
): number | undefined {
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
if (!modelLookupKey) {
return undefined;
}
return usage;
return this.modelContextWindowsByModelKey.get(modelLookupKey);
}
}
@@ -14,6 +14,33 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
}
describe("translateOpenCodeEvent", () => {
it("resolves context window max tokens from assistant message.updated model metadata", () => {
const resolvedContextWindowMaxTokens: number[] = [];
const state = createState();
state.modelContextWindowsByModelKey = new Map([["anthropic/claude-sonnet-4", 200_000]]);
state.onAssistantModelContextWindowResolved = (contextWindowMaxTokens) => {
resolvedContextWindowMaxTokens.push(contextWindowMaxTokens);
};
translateOpenCodeEvent(
{
type: "message.updated",
properties: {
info: {
id: "message-model-1",
sessionID: "session-1",
role: "assistant",
providerID: "anthropic",
modelID: "claude-sonnet-4",
},
},
},
state,
);
expect(resolvedContextWindowMaxTokens).toEqual([200_000]);
});
it("does not duplicate assistant output when completed part echoes streamed delta", () => {
const state = createState();
@@ -0,0 +1,248 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const execFileMock = vi.hoisted(() =>
vi.fn(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
),
);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: execFileMock,
};
});
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
warn: vi.fn(),
};
return logger;
}
describe("BackgroundGitFetchManager", () => {
beforeEach(() => {
vi.useFakeTimers();
execFileMock.mockReset();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
);
});
afterEach(() => {
vi.useRealTimers();
});
test("creates a fetch timer for a repo with an origin remote", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(target).toBeDefined();
expect(target.intervalId).toBeTruthy();
expect(execFileMock).toHaveBeenNthCalledWith(
1,
"git",
["remote", "get-url", "origin"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
expect(execFileMock).toHaveBeenNthCalledWith(
2,
"git",
["fetch", "origin", "--prune"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
subscription.unsubscribe();
manager.dispose();
});
test("dedupes multiple subscribers for the same repo root behind one timer", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listenerOne = vi.fn();
const listenerTwo = vi.fn();
const subscriptionOne = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
listenerOne,
);
const subscriptionTwo = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" },
listenerTwo,
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(managerAny.targets.size).toBe(1);
expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo]));
expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1);
subscriptionOne.unsubscribe();
subscriptionTwo.unsubscribe();
manager.dispose();
});
test("cleans up the timer when the last subscriber unsubscribes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
const intervalId = target.intervalId;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
subscription.unsubscribe();
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
manager.dispose();
});
test("logs fetch errors without crashing", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
if (args[0] === "remote") {
callback(null, "", "");
return;
}
callback(new Error("fetch failed"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
"Running background git fetch",
);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo",
},
"Background git fetch failed",
);
manager.dispose();
});
test("calls listeners when a fetch completes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(180_000);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(2);
manager.dispose();
});
test("does not create a timer when the repo has no origin remote", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(new Error("missing origin"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
expect((manager as any).targets.size).toBe(0);
subscription.unsubscribe();
manager.dispose();
});
test("dispose clears timers and listeners", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener);
await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn());
await flushPromises();
const managerAny = manager as any;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
manager.dispose();
expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
});
});
@@ -0,0 +1,150 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type pino from "pino";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
const execFileAsync = promisify(execFile);
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
type BackgroundGitFetchTarget = {
repoGitRoot: string;
cwd: string;
listeners: Set<() => void>;
intervalId: NodeJS.Timeout | null;
fetchInFlight: boolean;
};
export class BackgroundGitFetchManager {
private readonly logger: pino.Logger;
private readonly targets = new Map<string, BackgroundGitFetchTarget>();
constructor(options: { logger: pino.Logger }) {
this.logger = options.logger.child({ module: "background-git-fetch-manager" });
}
async subscribe(
params: { repoGitRoot: string; cwd: string },
listener: () => void,
): Promise<{ unsubscribe: () => void }> {
const existingTarget = this.targets.get(params.repoGitRoot);
if (existingTarget) {
existingTarget.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const hasOrigin = await this.hasOriginRemote(params.cwd);
if (!hasOrigin) {
return { unsubscribe: () => {} };
}
const targetAfterProbe = this.targets.get(params.repoGitRoot);
if (targetAfterProbe) {
targetAfterProbe.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const target: BackgroundGitFetchTarget = {
repoGitRoot: params.repoGitRoot,
cwd: params.cwd,
listeners: new Set([listener]),
intervalId: setInterval(() => {
void this.runFetch(target);
}, BACKGROUND_GIT_FETCH_INTERVAL_MS),
fetchInFlight: false,
};
this.targets.set(params.repoGitRoot, target);
void this.runFetch(target);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
dispose(): void {
for (const target of this.targets.values()) {
this.closeTarget(target);
}
this.targets.clear();
}
private closeTarget(target: BackgroundGitFetchTarget): void {
if (target.intervalId) {
clearInterval(target.intervalId);
target.intervalId = null;
}
target.listeners.clear();
}
private removeListener(targetKey: string, listener: () => void): void {
const target = this.targets.get(targetKey);
if (!target) {
return;
}
target.listeners.delete(listener);
if (target.listeners.size > 0) {
return;
}
this.closeTarget(target);
this.targets.delete(targetKey);
}
private async hasOriginRemote(cwd: string): Promise<boolean> {
try {
await execFileAsync("git", ["remote", "get-url", "origin"], {
cwd,
env: {
...READ_ONLY_GIT_ENV,
GIT_TERMINAL_PROMPT: "0",
},
});
return true;
} catch {
return false;
}
}
private async runFetch(target: BackgroundGitFetchTarget): Promise<void> {
if (target.fetchInFlight) {
return;
}
target.fetchInFlight = true;
this.logger.debug(
{ repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Running background git fetch",
);
try {
await execFileAsync("git", ["fetch", "origin", "--prune"], {
cwd: target.cwd,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
},
});
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch failed",
);
} finally {
target.fetchInFlight = false;
for (const listener of target.listeners) {
listener();
}
}
}
}
@@ -123,11 +123,14 @@ export class CheckoutDiffManager {
}
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
@@ -135,6 +138,7 @@ export class CheckoutDiffManager {
cwd,
compare.mode,
compare.mode === "base" ? (compare.baseRef ?? "") : "",
compare.ignoreWhitespace === true,
]);
}
@@ -206,6 +210,7 @@ export class CheckoutDiffManager {
{
mode: compare.mode,
baseRef: compare.baseRef,
ignoreWhitespace: compare.ignoreWhitespace,
includeStructured: true,
},
{ paseoHome: this.paseoHome },
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => {
it("lists available editors in deterministic order", () => {
const available = new Set(["code", "cursor", "explorer"]);
const editors = listAvailableEditorTargets({
platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null),
});
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "explorer", label: "Explorer" },
]);
});
it("returns Finder on macOS", () => {
const editors = listAvailableEditorTargets({
platform: "darwin",
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
});
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
});
it("returns the generic file manager target on Linux", () => {
const editors = listAvailableEditorTargets({
platform: "linux",
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
});
expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]);
});
it("launches editors as detached processes", async () => {
const unref = vi.fn();
const once = vi.fn((event: string, handler: () => void) => {
if (event === "spawn") {
queueMicrotask(handler);
}
return child;
});
const child = { once, unref };
const spawn = vi.fn(() => child as any);
await openInEditorTarget(
{
editorId: "vscode",
path: "/tmp/repo",
},
{
platform: "darwin",
existsSync: () => true,
findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null),
spawn,
},
);
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
detached: true,
shell: false,
stdio: "ignore",
});
expect(unref).toHaveBeenCalled();
});
it("rejects relative paths", async () => {
await expect(
openInEditorTarget(
{
editorId: "cursor",
path: "repo",
},
{
existsSync: () => true,
findExecutable: () => "/usr/local/bin/cursor",
},
),
).rejects.toThrow("Editor target path must be an absolute local path");
});
it("rejects platform-specific targets that are unavailable on this OS", async () => {
await expect(
openInEditorTarget(
{
editorId: "finder",
path: "/tmp/repo",
},
{
platform: "linux",
existsSync: () => true,
findExecutable: () => "/usr/bin/open",
},
),
).rejects.toThrow("Editor target unavailable: Finder");
});
});
@@ -0,0 +1,168 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
type EditorTargetDefinition = {
id: EditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
};
type ListAvailableEditorTargetsDependencies = {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null;
};
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
existsSync?: typeof existsSync;
spawn?: typeof spawn;
};
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
{
id: "file-manager",
label: "File Manager",
command: "xdg-open",
excludedPlatforms: ["darwin", "win32"],
},
];
function isAbsolutePath(value: string): boolean {
return posix.isAbsolute(value) || win32.isAbsolute(value);
}
function isTargetSupportedOnPlatform(
target: EditorTargetDefinition,
platform: NodeJS.Platform,
): boolean {
if (target.platforms && !target.platforms.includes(platform)) {
return false;
}
if (target.excludedPlatforms?.includes(platform)) {
return false;
}
return true;
}
function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition {
const target = EDITOR_TARGETS.find((entry) => entry.id === editorId);
if (!target) {
throw new Error(`Unknown editor target: ${editorId}`);
}
return target;
}
export function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): EditorTargetDescriptorPayload[] {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
return EDITOR_TARGETS.flatMap((target) => {
if (!isTargetSupportedOnPlatform(target, platform)) {
return [];
}
const executable = findExecutableFn(target.command);
if (!executable) {
return [];
}
return [
{
id: target.id,
label: target.label,
},
];
});
}
type Launch = {
command: string;
args: string[];
};
function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: typeof findExecutable;
}): Launch {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = input.findExecutableFn(target.command);
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
return {
command: executable,
args: [input.path],
};
}
export async function openInEditorTarget(
input: {
editorId: EditorTargetId;
path: string;
},
dependencies: OpenInEditorTargetDependencies = {},
): Promise<void> {
const platform = dependencies.platform ?? process.platform;
const pathToOpen = input.path.trim();
const existsSyncFn = dependencies.existsSync ?? existsSync;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
const spawnFn = dependencies.spawn ?? spawn;
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
throw new Error("Editor target path must be an absolute local path");
}
if (!existsSyncFn(pathToOpen)) {
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const launch = resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,
findExecutableFn,
});
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
const args =
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnFn(command, args, {
detached: true,
shell: platform === "win32",
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
+295 -71
View File
@@ -29,6 +29,7 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
@@ -49,6 +50,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
@@ -66,6 +68,7 @@ import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
import { spawnWorkspaceService } from "./worktree-bootstrap.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -251,6 +254,17 @@ export function resolveCreateAgentTitles(options: {
};
}
export function resolveWaitForFinishError(options: {
status: "permission" | "error" | "idle";
final: AgentSnapshotPayload | null;
}): string | null {
if (options.status !== "error") {
return null;
}
const message = options.final?.lastError;
return typeof message === "string" && message.trim().length > 0 ? message : "Agent failed";
}
type ProcessingPhase = "idle" | "transcribing";
type WorkspaceGitWatchTarget = {
@@ -395,6 +409,7 @@ export type SessionOptions = {
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
agentLoadingService?: AgentLoadingService;
backgroundGitFetchManager: BackgroundGitFetchManager;
createAgentMcpTransport: AgentMcpTransportFactory;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
@@ -565,6 +580,7 @@ export class Session {
private readonly loopService: LoopService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly agentLoadingService: AgentLoadingService;
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
@@ -604,6 +620,7 @@ export class Session {
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
private readonly registerVoiceSpeakHandler?: (
agentId: string,
@@ -643,6 +660,7 @@ export class Session {
loopService,
checkoutDiffManager,
agentLoadingService,
backgroundGitFetchManager,
createAgentMcpTransport,
stt,
tts,
@@ -687,6 +705,8 @@ export class Session {
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.backgroundGitFetchManager = backgroundGitFetchManager;
this.backgroundGitFetchManager = backgroundGitFetchManager;
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null;
@@ -1650,6 +1670,14 @@ export class Session {
await this.handleWorkspaceSetupStatusRequest(msg);
break;
case "list_available_editors_request":
await this.handleListAvailableEditorsRequest(msg);
break;
case "open_in_editor_request":
await this.handleOpenInEditorRequest(msg);
break;
case "open_project_request":
await this.handleOpenProjectRequest(msg);
break;
@@ -4077,6 +4105,9 @@ export class Session {
if (!target) {
return;
}
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId);
unsubscribeFetch?.();
this.workspaceGitFetchSubscriptions.delete(workspaceId);
this.closeWorkspaceGitWatchTarget(target);
this.workspaceGitWatchTargets.delete(workspaceId);
}
@@ -4123,7 +4154,7 @@ export class Session {
workspaces: Iterable<WorkspaceDescriptorPayload>,
): Promise<void> {
for (const workspace of workspaces) {
const persistedWorkspace = await this.workspaceRegistry.get(Number(workspace.id));
const persistedWorkspace = await this.findWorkspaceByDirectory(workspace.id);
if (!persistedWorkspace) {
continue;
}
@@ -4216,6 +4247,16 @@ export class Session {
}
this.workspaceGitWatchTargets.set(workspaceId, target);
const subscription = await this.backgroundGitFetchManager.subscribe(
{ repoGitRoot: refsRoot, cwd: workspaceId },
() => {
const activeTarget = this.workspaceGitWatchTargets.get(workspaceId);
if (activeTarget) {
this.scheduleWorkspaceGitWatchRefresh(activeTarget);
}
},
);
this.workspaceGitFetchSubscriptions.set(workspaceId, subscription.unsubscribe);
}
private async syncWorkspaceGitWatchTarget(
@@ -5240,7 +5281,28 @@ export class Session {
};
}
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
private async describeWorkspaceRecordWithGitData(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
return this.describeWorkspaceRecord(workspace, projectRecord);
}
private async buildWorkspaceDescriptor(input: {
workspace: PersistedWorkspaceRecord;
projectRecord?: PersistedProjectRecord | null;
includeGitData: boolean;
}): Promise<WorkspaceDescriptorPayload> {
if (input.includeGitData && input.projectRecord?.kind === "git") {
return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
}
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
}
private async buildWorkspaceDescriptorMap(options: {
includeGitData: boolean;
workspaceIds?: Iterable<string>;
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
this.listAgentPayloads(),
this.workspaceRegistry.list(),
@@ -5253,16 +5315,30 @@ export class Session {
.filter((project) => !project.archivedAt)
.map((project) => [project.id, project] as const),
);
const descriptorsByWorkspaceId = new Map<number, WorkspaceDescriptorPayload>();
const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [workspace.directory, workspace.id]));
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds
? new Set(
Array.from(options.workspaceIds, (workspaceId) =>
normalizePersistedWorkspaceId(workspaceId),
),
)
: null;
const workspaceIdsByDirectory = new Map(
activeRecords.map((workspace) => [workspace.directory, workspace.directory] as const),
);
for (const workspace of activeRecords) {
if (workspaceIds && !workspaceIds.has(workspace.directory)) {
continue;
}
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
descriptorsByWorkspaceId.set(
workspace.id,
await this.describeWorkspaceRecord(
workspace.directory,
await this.buildWorkspaceDescriptor({
workspace,
activeProjects.get(workspace.projectId) ?? null,
),
projectRecord,
includeGitData: options.includeGitData,
}),
);
}
@@ -5290,7 +5366,41 @@ export class Session {
existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent);
}
return Array.from(descriptorsByWorkspaceId.values());
return descriptorsByWorkspaceId;
}
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
return Array.from(
(await this.buildWorkspaceDescriptorMap({
includeGitData: false,
})).values(),
);
}
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.directory === normalizedCwd);
if (exact) {
return exact.directory;
}
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
const prefix = workspace.directory.endsWith(sep)
? workspace.directory
: `${workspace.directory}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.directory.length > bestMatch.directory.length) {
bestMatch = workspace;
}
}
return bestMatch?.directory ?? normalizedCwd;
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
@@ -5328,7 +5438,7 @@ export class Session {
case "name":
return workspace.name.toLocaleLowerCase();
case "project_id":
return workspace.projectId;
return workspace.projectId.toLocaleLowerCase();
}
}
@@ -5346,7 +5456,7 @@ export class Session {
}
return spec.direction === "asc" ? base : -base;
}
return Number(left.id) - Number(right.id);
return left.id.localeCompare(right.id);
}
private encodeFetchWorkspacesCursor(
@@ -5388,7 +5498,7 @@ export class Session {
id?: unknown;
};
if (!Array.isArray(payload.sort) || (typeof payload.id !== "number" && typeof payload.id !== "string")) {
if (!Array.isArray(payload.sort) || typeof payload.id !== "string") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
if (!payload.values || typeof payload.values !== "object") {
@@ -5455,7 +5565,7 @@ export class Session {
}
return spec.direction === "asc" ? base : -base;
}
return Number(workspace.id) - Number(cursor.id);
return workspace.id.localeCompare(cursor.id);
}
private matchesWorkspaceFilter(input: {
@@ -5467,8 +5577,8 @@ export class Session {
return true;
}
if (typeof filter.projectId === "number") {
if (workspace.projectId !== filter.projectId) {
if (filter.projectId && filter.projectId.trim().length > 0) {
if (workspace.projectId !== filter.projectId.trim()) {
return false;
}
}
@@ -5707,46 +5817,73 @@ export class Session {
}
}
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: { dedupeGitState?: boolean },
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
if (!this.workspaceUpdatesSubscription) {
return;
}
try {
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
if (changedWorkspaceIds.size === 0) {
return;
}
await this.emitWorkspaceUpdatesForWorkspaceIds(changedWorkspaceIds, {
skipReconcile: true,
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed");
}
}
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
return new Set();
}
private async emitWorkspaceUpdatesForWorkspaceIds(
workspaceIds: Iterable<string>,
options?: { dedupeGitState?: boolean; skipReconcile?: boolean },
): Promise<void> {
const subscription = this.workspaceUpdatesSubscription;
if (!subscription) {
return;
}
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.directory] : [];
const uniqueWorkspaceIds = new Set(
Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)),
);
if (uniqueWorkspaceIds.size === 0) {
return;
}
for (const nextWorkspaceId of workspaceIdsToEmit) {
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
workspaceIds: uniqueWorkspaceIds,
includeGitData: true,
});
for (const workspaceId of uniqueWorkspaceIds) {
const workspace = descriptorsByWorkspaceId.get(workspaceId);
const nextWorkspace =
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
? workspace
: null;
if (
options?.dedupeGitState &&
this.shouldSkipWorkspaceGitWatchUpdate(normalizedCwd, nextWorkspace)
this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace)
) {
continue;
}
const watchTarget = this.workspaceGitWatchTargets.get(normalizedCwd);
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
if (watchTarget && this.onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== watchTarget.lastBranchName) {
this.onBranchChanged(normalizedCwd, watchTarget.lastBranchName, newBranchName);
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
}
}
this.rememberWorkspaceGitWatchFingerprint(normalizedCwd, nextWorkspace);
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace);
if (!nextWorkspace) {
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: nextWorkspaceId,
id: workspaceId,
});
continue;
}
@@ -5756,50 +5893,53 @@ export class Session {
workspace: nextWorkspace,
});
}
if (!options?.skipReconcile) {
void this.reconcileAndEmitWorkspaceUpdates();
}
}
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
if (!this.workspaceUpdatesSubscription) {
private scheduleWorkspaceGitBootstrapUpdates(options: {
subscriptionId: string;
workspaces: Iterable<WorkspaceDescriptorPayload>;
}): void {
const gitWorkspaceIds = Array.from(options.workspaces, (workspace) => workspace)
.filter((workspace) => workspace.projectKind === "git")
.map((workspace) => workspace.id);
if (gitWorkspaceIds.length === 0) {
return;
}
const uniqueWorkspaceCwds = new Set<string>();
for (const cwd of cwds) {
const normalized = await this.resolveWorkspaceDirectory(cwd);
if (!normalized) {
continue;
queueMicrotask(() => {
if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) {
return;
}
uniqueWorkspaceCwds.add(normalized);
}
const subscription = this.workspaceUpdatesSubscription;
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
for (const workspaceCwd of uniqueWorkspaceCwds) {
const persistedWorkspace = await this.findWorkspaceByDirectory(workspaceCwd);
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(persistedWorkspace.directory) : null;
const nextWorkspace =
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
? workspace
: null;
this.rememberWorkspaceGitWatchFingerprint(workspaceCwd, nextWorkspace);
if (!nextWorkspace) {
if (persistedWorkspace) {
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: persistedWorkspace.directory,
});
}
continue;
}
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "upsert",
workspace: nextWorkspace,
void this.emitWorkspaceUpdatesForWorkspaceIds(gitWorkspaceIds, {
skipReconcile: true,
});
});
}
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: { dedupeGitState?: boolean },
): Promise<void> {
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
}
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const uniqueWorkspaceIds = new Set<string>();
for (const cwd of cwds) {
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces));
}
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
}
private async handleFetchAgents(
@@ -5905,6 +6045,11 @@ export class Session {
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
void this.reconcileAndEmitWorkspaceUpdates();
this.scheduleWorkspaceGitBootstrapUpdates({
subscriptionId,
workspaces: payload.entries,
});
}
} catch (error) {
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
@@ -5931,7 +6076,7 @@ export class Session {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
await this.emitWorkspaceUpdateForCwd(workspace.directory);
const descriptor = await this.describeWorkspaceRecord(workspace);
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.emit({
type: "open_project_response",
payload: {
@@ -5954,7 +6099,9 @@ export class Session {
}
}
private buildWorkspaceServicePayloadSnapshot(workspaceDirectory: string): WorkspaceDescriptorPayload["services"] {
private buildWorkspaceServicePayloadSnapshot(
workspaceDirectory: string,
): WorkspaceDescriptorPayload["services"] {
if (!this.serviceRouteStore) {
return [];
}
@@ -5976,6 +6123,14 @@ export class Session {
});
}
async getAvailableEditorTargets() {
return listAvailableEditorTargets();
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleStartWorkspaceServiceRequest(
request: StartWorkspaceServiceRequest,
): Promise<void> {
@@ -6035,13 +6190,76 @@ export class Session {
}
}
private async handleListAvailableEditorsRequest(
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
): Promise<void> {
try {
const editors = await this.getAvailableEditorTargets();
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list available editors";
this.sessionLogger.error(
{ err: error, requestType: request.type },
"Failed to list available editors",
);
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors: [],
error: message,
},
});
}
}
private async handleOpenInEditorRequest(
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
): Promise<void> {
try {
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to open in editor";
this.sessionLogger.error(
{
err: error,
editorId: request.editorId,
path: request.path,
requestType: request.type,
},
"Failed to open in editor",
);
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: message,
},
});
}
}
private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),
@@ -6438,9 +6656,10 @@ export class Session {
: record.lastStatus === "error"
? "error"
: "idle";
const error = resolveWaitForFinishError({ status, final });
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null, lastMessage: null },
payload: { requestId, status, final, error, lastMessage: null },
});
return;
}
@@ -6467,10 +6686,11 @@ export class Session {
: result.status === "error"
? "error"
: "idle";
const error = resolveWaitForFinishError({ status, final });
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
payload: { requestId, status, final, error, lastMessage: result.lastMessage },
});
} catch (error) {
const isAbort =
@@ -7212,6 +7432,10 @@ export class Session {
}
this.checkoutDiffSubscriptions.clear();
for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) {
unsubscribe();
}
this.workspaceGitFetchSubscriptions.clear();
for (const target of this.workspaceGitWatchTargets.values()) {
this.closeWorkspaceGitWatchTarget(target);
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { resolveWaitForFinishError } from "./session.js";
describe("resolveWaitForFinishError", () => {
test("returns the agent error when the wait result is an error", () => {
expect(
resolveWaitForFinishError({
status: "error",
final: { lastError: "invalid_json_schema" } as any,
}),
).toBe("invalid_json_schema");
});
test("returns a generic fallback when the agent ended in error without a message", () => {
expect(
resolveWaitForFinishError({
status: "error",
final: {} as any,
}),
).toBe("Agent failed");
});
test("returns null for non-error wait results", () => {
expect(
resolveWaitForFinishError({
status: "idle",
final: { lastError: "should not surface" } as any,
}),
).toBeNull();
});
});
@@ -48,7 +48,14 @@ vi.mock("node:fs", async () => {
});
vi.mock("./checkout-git-utils.js", () => ({
READ_ONLY_GIT_ENV: {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
},
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
toCheckoutError: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
vi.mock("@getpaseo/highlight", () => ({
@@ -61,10 +68,33 @@ function createSessionForWorkspaceGitWatchTests(): {
emitted: Array<{ type: string; payload: unknown }>;
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
backgroundGitFetchManager: {
subscribe: ReturnType<typeof vi.fn>;
subscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
unsubscribe: ReturnType<typeof vi.fn>;
}>;
};
logger: {
child: () => unknown;
trace: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
} {
const emitted: Array<{ type: string; payload: unknown }> = [];
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
let nextProjectId = 1;
let nextWorkspaceId = 1;
const backgroundGitFetchSubscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
unsubscribe: ReturnType<typeof vi.fn>;
}> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
@@ -73,6 +103,19 @@ function createSessionForWorkspaceGitWatchTests(): {
warn: vi.fn(),
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(
async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
const unsubscribe = vi.fn();
backgroundGitFetchSubscriptions.push({
params,
listener,
unsubscribe,
});
return { unsubscribe };
},
),
};
const session = new Session({
clientId: "test-client",
@@ -95,7 +138,11 @@ function createSessionForWorkspaceGitWatchTests(): {
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
insert: async () => 0,
insert: async (record: Omit<ReturnType<typeof createPersistedProjectRecord>, "id">) => {
const id = nextProjectId++;
projects.set(id, createPersistedProjectRecord({ id, ...record }));
return id;
},
upsert: async (record: any) => {
projects.set(record.id, record);
},
@@ -119,7 +166,11 @@ function createSessionForWorkspaceGitWatchTests(): {
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
insert: async () => 0,
insert: async (record: Omit<ReturnType<typeof createPersistedWorkspaceRecord>, "id">) => {
const id = nextWorkspaceId++;
workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record }));
return id;
},
upsert: async (record: any) => {
workspaces.set(record.id, record);
},
@@ -152,6 +203,7 @@ function createSessionForWorkspaceGitWatchTests(): {
}),
dispose: () => {},
} as any,
backgroundGitFetchManager: backgroundGitFetchManager as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
@@ -162,7 +214,17 @@ function createSessionForWorkspaceGitWatchTests(): {
(session as any).listAgentPayloads = async () => [];
return { session, emitted, projects, workspaces };
return {
session,
emitted,
projects,
workspaces,
backgroundGitFetchManager: {
subscribe: backgroundGitFetchManager.subscribe,
subscriptions: backgroundGitFetchSubscriptions,
},
logger,
};
}
function seedGitWorkspace(input: {
@@ -233,19 +295,20 @@ describe("workspace git watch targets", () => {
};
let descriptor = {
id: "10",
projectId: "1",
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: { additions: 1, deletions: 0 },
workspaceDirectory: "/tmp/repo",
};
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
@@ -274,7 +337,7 @@ describe("workspace git watch targets", () => {
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: "10",
id: "/tmp/repo",
name: "renamed-branch",
diffStat: { additions: 1, deletions: 0 },
},
@@ -317,15 +380,16 @@ describe("workspace git watch targets", () => {
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: 20,
projectId: 2,
id: "/tmp/repo-one",
projectId: "/tmp/repo-one",
projectDisplayName: "repo-one",
projectRootPath: "/tmp/repo-one",
projectKind: "git",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
workspaceDirectory: "/tmp/repo-one",
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
@@ -341,15 +405,16 @@ describe("workspace git watch targets", () => {
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: 30,
projectId: 3,
id: "/tmp/repo-two",
projectId: "/tmp/repo-two",
projectDisplayName: "repo-two",
projectRootPath: "/tmp/repo-two",
projectKind: "git",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
workspaceDirectory: "/tmp/repo-two",
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
@@ -379,4 +444,143 @@ describe("workspace git watch targets", () => {
await session.cleanup();
}
});
test("subscribes to the background fetch manager when a git watch target is created", async () => {
const { session, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 4,
workspaceId: 40,
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
expect.any(Function),
);
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => {
const { session, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 5,
workspaceId: 50,
cwd: "/tmp/repo",
name: "main",
});
seedGitWorkspace({
projects,
workspaces,
projectId: 6,
workspaceId: 60,
cwd: "/tmp/repo-feature",
name: "feature",
});
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) =>
cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature",
);
sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git");
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo-feature", { isGit: true });
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2);
expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo",
});
expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo-feature",
});
sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo");
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled();
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => {
const { session, emitted, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 7,
workspaceId: 70,
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-1",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
let descriptor = {
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: { additions: 1, deletions: 0 },
};
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
descriptor = {
...descriptor,
name: "updated-after-fetch",
};
backgroundGitFetchManager.subscriptions[0]?.listener();
await vi.advanceTimersByTimeAsync(500);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as any[];
expect(workspaceUpdates).toHaveLength(1);
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: "/tmp/repo",
name: "updated-after-fetch",
},
});
await session.cleanup();
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
});
});
@@ -85,6 +85,9 @@ function createSessionForWorkspaceTests(): {
warn: vi.fn(),
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn() })),
};
const session = new Session({
clientId: "test-client",
@@ -172,6 +175,7 @@ function createSessionForWorkspaceTests(): {
}),
dispose: () => {},
} as any,
backgroundGitFetchManager: backgroundGitFetchManager as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
@@ -988,38 +992,47 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
];
(session as any).reconcileActiveWorkspaceRecords = async () => new Set();
(session as any).buildWorkspaceDescriptorMap = async () =>
new Map([
[
"/tmp/repo",
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
],
]);
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
];
(session as any).buildWorkspaceDescriptorMap = async () =>
new Map([
[
"/tmp/repo",
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
],
]);
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update");
@@ -1109,6 +1122,195 @@ describe("workspace aggregation", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("workspace update fanout for multiple cwd values is deduplicated", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const { session, projects, workspaces } = createSessionForWorkspaceTests();
const sessionAny = session as any;
seedProject({
projects,
id: 6,
directory: "/tmp/repo",
displayName: "repo",
kind: "git",
});
seedWorkspace({
workspaces,
id: 60,
projectId: 6,
directory: "/tmp/repo",
displayName: "main",
kind: "checkout",
});
seedWorkspace({
workspaces,
id: 61,
projectId: 6,
directory: "/tmp/repo/worktree",
displayName: "feature",
kind: "worktree",
});
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-dedup",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([
[
"/tmp/repo",
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
services: [],
},
],
[
"/tmp/repo/worktree",
{
id: "/tmp/repo/worktree",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo/worktree",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
],
]);
sessionAny.emit = (message: any) => emitted.push(message);
await sessionAny.emitWorkspaceUpdatesForCwds(["/tmp/repo/worktree", "/tmp/repo"]);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as any[];
expect(workspaceUpdates).toHaveLength(2);
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]);
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/worktree",
]);
});
test("open_project_request registers a workspace before any agent exists", async () => {
const { session, emitted, workspaces } = createSessionForWorkspaceTests();
const sessionAny = session as any;
sessionAny.resolveWorkspaceDirectory = async (cwd: string) => cwd;
await sessionAny.handleMessage({
type: "open_project_request",
cwd: "/tmp/repo",
requestId: "req-open",
});
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === "/tmp/repo")).toBe(
true,
);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe("/tmp/repo");
});
test("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const { session, emitted, workspaces } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const repoRoot = "/tmp/repo";
const subdir = "/tmp/repo/packages/app";
sessionAny.resolveWorkspaceDirectory = async () => repoRoot;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: repoRoot,
projectName: "repo",
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: repoRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
await sessionAny.handleMessage({
type: "open_project_request",
cwd: subdir,
requestId: "req-open-subdir",
});
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === repoRoot)).toBe(
true,
);
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === subdir)).toBe(
false,
);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(repoRoot);
});
test("list_available_editors_request returns available targets", async () => {
const { session, emitted } = createSessionForWorkspaceTests();
const sessionAny = session as any;
sessionAny.getAvailableEditorTargets = async () => [
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
];
await sessionAny.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors",
});
const response = emitted.find(
(message) => message.type === "list_available_editors_response",
) as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
]);
});
test("open_in_editor_request launches the selected target", async () => {
const { session, emitted } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const calls: Array<{ editorId: string; path: string }> = [];
sessionAny.openEditorTarget = async (input: { editorId: string; path: string }) => {
calls.push(input);
};
await sessionAny.handleMessage({
type: "open_in_editor_request",
requestId: "req-open-editor",
editorId: "vscode",
path: "/tmp/repo",
});
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
const response = emitted.find(
(message) => message.type === "open_in_editor_response",
) as any;
expect(response?.payload.error).toBeNull();
});
test("archive_workspace_request archives the persisted workspace row", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
@@ -1550,4 +1752,186 @@ describe("backward compatibility", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => {
const { session } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const project = createPersistedProjectRecord({
id: 70,
directory: "/tmp/repo",
kind: "git",
displayName: "repo",
gitRemote: "https://github.com/acme/repo.git",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
id: 71,
projectId: project.id,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
sessionAny.listAgentPayloads = async () => [];
sessionAny.projectRegistry.list = async () => [project];
sessionAny.workspaceRegistry.list = async () => [workspace];
const baselineDescriptor = {
id: workspace.directory,
projectId: project.directory,
projectDisplayName: project.displayName,
projectRootPath: project.directory,
workspaceDirectory: workspace.directory,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
services: [],
} as const;
const gitDescriptor = {
...baselineDescriptor,
diffStat: { additions: 3, deletions: 1 },
} as const;
sessionAny.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor);
sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor);
const descriptors = await sessionAny.listWorkspaceDescriptorsSnapshot();
expect(sessionAny.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project);
expect(sessionAny.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled();
expect(descriptors).toEqual([baselineDescriptor]);
});
test("subscribed fetch_workspaces emits git enrichment updates after the baseline snapshot", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const { session } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const gitProject = createPersistedProjectRecord({
id: 80,
directory: "/tmp/repo",
kind: "git",
displayName: "repo",
gitRemote: "https://github.com/acme/repo.git",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const directoryProject = createPersistedProjectRecord({
id: 81,
directory: "/tmp/docs",
kind: "directory",
displayName: "docs",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const gitWorkspace = createPersistedWorkspaceRecord({
id: 82,
projectId: gitProject.id,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const directoryWorkspace = createPersistedWorkspaceRecord({
id: 83,
projectId: directoryProject.id,
directory: "/tmp/docs",
kind: "checkout",
displayName: "docs",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const baselineGitDescriptor = {
id: gitWorkspace.directory,
projectId: gitProject.directory,
projectDisplayName: gitProject.displayName,
projectRootPath: gitProject.directory,
workspaceDirectory: gitWorkspace.directory,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
services: [],
} as const;
const enrichedGitDescriptor = {
...baselineGitDescriptor,
diffStat: { additions: 3, deletions: 1 },
} as const;
const directoryDescriptor = {
id: directoryWorkspace.directory,
projectId: directoryProject.directory,
projectDisplayName: directoryProject.displayName,
projectRootPath: directoryProject.directory,
workspaceDirectory: directoryWorkspace.directory,
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "docs",
status: "done",
activityAt: null,
diffStat: null,
services: [],
} as const;
sessionAny.emit = (message: any) => emitted.push(message);
sessionAny.listAgentPayloads = async () => [];
sessionAny.projectRegistry.list = async () => [gitProject, directoryProject];
sessionAny.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace];
sessionAny.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {});
sessionAny.describeWorkspaceRecord = vi.fn(
async (workspace: typeof gitWorkspace | typeof directoryWorkspace, project: any) => {
if (workspace.id === gitWorkspace.id) {
expect(project).toEqual(gitProject);
return baselineGitDescriptor;
}
expect(project).toEqual(directoryProject);
return directoryDescriptor;
},
);
sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => enrichedGitDescriptor);
await sessionAny.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-fetch-workspaces",
subscribe: {},
});
await new Promise((resolve) => setTimeout(resolve, 0));
const response = emitted.find(
(message) => message.type === "fetch_workspaces_response",
) as { type: "fetch_workspaces_response"; payload: any } | undefined;
expect(
response?.payload.entries.map((entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
entry.id,
entry.diffStat,
]),
).toEqual([
[directoryDescriptor.id, directoryDescriptor.diffStat],
[baselineGitDescriptor.id, baselineGitDescriptor.diffStat],
]);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as Array<{ type: "workspace_update"; payload: any }>;
expect(workspaceUpdates).toEqual([
{
type: "workspace_update",
payload: {
kind: "upsert",
workspace: enrichedGitDescriptor,
},
},
]);
expect(sessionAny.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith(
gitWorkspace,
gitProject,
);
});
});
@@ -13,6 +13,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
import {
type ServerInfoStatusPayload,
type WSHelloMessage,
@@ -239,6 +240,7 @@ export class VoiceAssistantWebSocketServer {
private readonly loopService: LoopService;
private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly pushTokenStore: PushTokenStore;
@@ -356,6 +358,9 @@ export class VoiceAssistantWebSocketServer {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
this.checkoutDiffManager = checkoutDiffManager;
this.backgroundGitFetchManager = new BackgroundGitFetchManager({
logger: this.logger,
});
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.createAgentMcpTransport = createAgentMcpTransport;
@@ -541,6 +546,7 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
this.backgroundGitFetchManager.dispose();
this.checkoutDiffManager.dispose();
this.pendingConnections.clear();
this.sessions.clear();
@@ -687,6 +693,7 @@ export class VoiceAssistantWebSocketServer {
loopService: this.loopService,
scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager,
backgroundGitFetchManager: this.backgroundGitFetchManager,
createAgentMcpTransport: this.createAgentMcpTransport,
stt: () => this.speech?.resolveStt() ?? null,
tts: () => this.speech?.resolveTts() ?? null,
@@ -203,6 +203,74 @@ describe("shared messages stream parsing", () => {
}
});
it("parses optional permission actions and selectedActionId compatibly", () => {
const requestParsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "permission_requested",
provider: "codex",
request: {
id: "perm-1",
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: { plan: "- step 1" },
actions: [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
],
},
},
},
});
expect(requestParsed.payload.event.type).toBe("permission_requested");
if (requestParsed.payload.event.type === "permission_requested") {
expect(requestParsed.payload.event.request.actions).toHaveLength(2);
expect(requestParsed.payload.event.request.actions?.[1]?.label).toBe("Implement");
}
const resolutionParsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:01.000Z",
event: {
type: "permission_resolved",
provider: "claude",
requestId: "perm-1",
resolution: {
behavior: "allow",
selectedActionId: "implement_resume",
},
},
},
});
expect(resolutionParsed.payload.event.type).toBe("permission_resolved");
if (resolutionParsed.payload.event.type === "permission_resolved") {
expect(resolutionParsed.payload.event.resolution).toEqual({
behavior: "allow",
selectedActionId: "implement_resume",
});
}
});
it("rejects removed initialize_agent_request inbound payload", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request",
+68
View File
@@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
cachedInputTokens: z.number().optional(),
outputTokens: z.number().optional(),
totalCostUsd: z.number().optional(),
contextWindowMaxTokens: z.number().optional(),
contextWindowUsedTokens: z.number().optional(),
});
const McpStdioServerConfigSchema = z.object({
@@ -197,15 +199,24 @@ const AgentSessionConfigSchema = z.object({
});
const AgentPermissionUpdateSchema = z.record(z.unknown());
const AgentPermissionActionSchema = z.object({
id: z.string(),
label: z.string(),
behavior: z.enum(["allow", "deny"]),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
});
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
@@ -220,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
description: z.string().optional(),
input: z.record(z.unknown()).optional(),
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
actions: z.array(AgentPermissionActionSchema).optional(),
metadata: z.record(z.unknown()).optional(),
});
@@ -978,6 +990,7 @@ const CheckoutErrorSchema = z.object({
const CheckoutDiffCompareSchema = z.object({
mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().optional(),
ignoreWhitespace: z.boolean().optional(),
});
export const CheckoutStatusRequestSchema = z.object({
@@ -1098,6 +1111,32 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
requestId: z.string(),
});
export const EditorTargetIdSchema = z.enum([
"cursor",
"vscode",
"zed",
"finder",
"explorer",
"file-manager",
]);
export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema,
label: z.string(),
});
export const ListAvailableEditorsRequestSchema = z.object({
type: z.literal("list_available_editors_request"),
requestId: z.string(),
});
export const OpenInEditorRequestSchema = z.object({
type: z.literal("open_in_editor_request"),
path: z.string(),
editorId: EditorTargetIdSchema,
requestId: z.string(),
});
export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"),
cwd: z.string(),
@@ -1366,6 +1405,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
WorkspaceSetupStatusRequestSchema,
ListAvailableEditorsRequestSchema,
OpenInEditorRequestSchema,
OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema,
@@ -1915,6 +1956,23 @@ export const StartWorkspaceServiceResponseMessageSchema = z.object({
}),
});
export const ListAvailableEditorsResponseMessageSchema = z.object({
type: z.literal("list_available_editors_response"),
payload: z.object({
requestId: z.string(),
editors: z.array(EditorTargetDescriptorPayloadSchema),
error: z.string().nullable(),
}),
});
export const OpenInEditorResponseMessageSchema = z.object({
type: z.literal("open_in_editor_response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});
export const ArchiveWorkspaceResponseMessageSchema = z.object({
type: z.literal("archive_workspace_response"),
payload: z.object({
@@ -2608,6 +2666,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
StartWorkspaceServiceResponseMessageSchema,
ListAvailableEditorsResponseMessageSchema,
OpenInEditorResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
@@ -2708,6 +2768,8 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
@@ -2715,6 +2777,10 @@ export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessa
export type StartWorkspaceServiceResponseMessage = z.infer<
typeof StartWorkspaceServiceResponseMessageSchema
>;
export type ListAvailableEditorsResponseMessage = z.infer<
typeof ListAvailableEditorsResponseMessageSchema
>;
export type OpenInEditorResponseMessage = z.infer<typeof OpenInEditorResponseMessageSchema>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
export type FetchAgentTimelineResponseMessage = z.infer<
@@ -2863,6 +2929,8 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_project_request");
});
test("parses list_available_editors_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "list_available_editors_request",
requestId: "req-editors",
});
expect(parsed.type).toBe("list_available_editors_request");
});
test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
payload: {
requestId: "req-open-editor",
error: null,
},
});
expect(parsed.type).toBe("open_in_editor_response");
});
test("rejects invalid workspace update payload", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update",
@@ -110,6 +110,21 @@ describe("checkout git utilities", () => {
expect(message).toBe("update file");
});
it("hides whitespace-only changes when requested", async () => {
writeFileSync(join(repoDir, "file.txt"), "hello \n");
const visibleDiff = await getCheckoutDiff(repoDir, { mode: "uncommitted" });
expect(visibleDiff.diff).toContain("file.txt");
const hiddenDiff = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
ignoreWhitespace: true,
includeStructured: true,
});
expect(hiddenDiff.diff).toBe("");
expect(hiddenDiff.structured).toEqual([]);
});
it("preserves removed-line syntax highlighting with structured diffs", async () => {
const originalContent = `/*
comment line 1
+48 -12
View File
@@ -309,13 +309,25 @@ export async function listBranchSuggestions(
return ordered.slice(0, limit);
}
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
async function listCheckoutFileChanges(
cwd: string,
ref: string,
ignoreWhitespace = false,
): Promise<CheckoutFileChange[]> {
const changes: CheckoutFileChange[] = [];
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
const { stdout: nameStatusOut } = await execFileAsync(
"git",
buildGitDiffArgs({
ignoreWhitespace,
extra: ["--name-status", ref],
}),
{
cwd,
env: READ_ONLY_GIT_ENV,
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
},
);
for (const line of nameStatusOut
.split("\n")
.map((l) => l.trim())
@@ -430,13 +442,24 @@ function normalizeNumstatPath(pathField: string): string {
return pathField;
}
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra];
}
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const TRACKED_MAX_CHANGED_LINES = 40_000;
async function getTrackedNumstatByPath(cwd: string, ref: string): Promise<Map<string, FileStat>> {
async function getTrackedNumstatByPath(
cwd: string,
ref: string,
ignoreWhitespace = false,
): Promise<Map<string, FileStat>> {
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--numstat", ref],
args: buildGitDiffArgs({
ignoreWhitespace,
extra: ["--numstat", ref],
}),
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
@@ -612,6 +635,7 @@ export interface CheckoutDiffResult {
export interface CheckoutDiffCompare {
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
includeStructured?: boolean;
}
@@ -1106,6 +1130,7 @@ function buildPlaceholderParsedDiffFile(
async function getUntrackedDiffText(
cwd: string,
change: CheckoutFileChange,
ignoreWhitespace = false,
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
@@ -1118,7 +1143,10 @@ async function getUntrackedDiffText(
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--no-index", "/dev/null", "--", change.path],
args: buildGitDiffArgs({
ignoreWhitespace,
extra: ["--no-index", "/dev/null", "--", change.path],
}),
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
@@ -1391,7 +1419,8 @@ export async function getCheckoutDiff(
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
}
const changes = await listCheckoutFileChanges(cwd, refForDiff);
const ignoreWhitespace = compare.ignoreWhitespace === true;
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
changes.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
@@ -1422,7 +1451,7 @@ export async function getCheckoutDiff(
const trackedNumstatByPath =
trackedChanges.length > 0
? await getTrackedNumstatByPath(cwd, refForDiff)
? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace)
: new Map<string, FileStat>();
const trackedDiffPaths: string[] = [];
const trackedPlaceholderByPath = new Map<
@@ -1448,7 +1477,10 @@ export async function getCheckoutDiff(
if (trackedDiffPaths.length > 0) {
const trackedDiffResult = await spawnLimitedText({
cmd: "git",
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
args: buildGitDiffArgs({
ignoreWhitespace,
extra: [refForDiff, "--", ...trackedDiffPaths],
}),
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TOTAL_DIFF_MAX_BYTES,
@@ -1537,7 +1569,11 @@ export async function getCheckoutDiff(
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
const { text, truncated, stat } = await getUntrackedDiffText(
cwd,
change,
ignoreWhitespace,
);
if (!compare.includeStructured) {
if (stat?.isBinary) {
@@ -149,6 +149,71 @@ describe("createWorktree", () => {
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
});
it("prefers origin/{branch} over local {branch} when both exist", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
execSync(`git init --bare ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
execSync("git config user.email 'test@test.com'", { cwd: remoteCloneDir });
execSync("git config user.name 'Test'", { cwd: remoteCloneDir });
writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n");
execSync("git add file.txt", { cwd: remoteCloneDir });
execSync("git -c commit.gpgsign=false commit -m 'advance origin main'", {
cwd: remoteCloneDir,
});
execSync("git push origin main", { cwd: remoteCloneDir });
writeFileSync(join(repoDir, "file.txt"), "from-local\n");
execSync("git add file.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'advance local main'", { cwd: repoDir });
execSync("git fetch origin", { cwd: repoDir });
const result = await createWorktree({
branchName: "prefer-origin-feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "prefer-origin-feature",
runSetup: false,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n");
});
it("falls back to local {branch} when origin/{branch} does not exist", async () => {
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
execSync("git add file.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'advance local main only'", { cwd: repoDir });
const result = await createWorktree({
branchName: "prefer-local-fallback-feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "prefer-local-fallback-feature",
runSetup: false,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-local-only\n");
});
it("throws when neither origin/{branch} nor local {branch} exists", async () => {
await expect(
createWorktree({
branchName: "missing-base-feature",
cwd: repoDir,
baseBranch: "does-not-exist",
worktreeSlug: "missing-base-feature",
runSetup: false,
paseoHome,
}),
).rejects.toThrow("Base branch not found: does-not-exist");
});
it("fails with invalid branch name", async () => {
await expect(
createWorktree({
+4 -5
View File
@@ -1046,15 +1046,14 @@ export async function createWorktree({
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
}
// Resolve the base branch - try local first, then remote
// Resolve the base branch - prefer origin/{branch}, then fall back to local
let resolvedBaseBranch = normalizedBaseBranch;
try {
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
} catch {
// Local branch doesn't exist, try remote (origin/{branch})
try {
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
}