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:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user