Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1943,7 +1943,7 @@ app.whenReady().then(async () => {
}
},
onDeleteEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesDeleteEvent, event),
onPlacementChanged: (event) => {
onPlacementChanged: async (event) => {
// Refresh the VM launch-context cache so subsequent
// resolveLaneLaunchContext() calls see the new placement.
// TODO(mac-vm-onboarding): emit a renderer-facing IPC event so the
Expand All @@ -1963,7 +1963,7 @@ app.whenReady().then(async () => {
});
}
try {
agentChatServiceRef?.handleLanePlacementChanged?.({
await agentChatServiceRef?.handleLanePlacementChanged?.({
laneId: event.laneId,
from: event.from,
to: event.to,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,40 @@ describe("spawnAgent tool", () => {
expect(setup.chat.createSession).not.toHaveBeenCalled();
});

it("does not treat planning phase done as plan approval", async () => {
setup = await setupWithRun("lead");
const manifest = setup.svc.getManifestForRun(setup.runId)!;
const patched = await setup.svc.manifestPatch(
{
runId: setup.runId,
ifMatchEtag: manifest.etag,
actorRole: "lead",
actorSessionId: "S-lead",
patches: [
{
op: "replace",
path: "/phases/{id:planning}/status",
value: "done",
},
],
},
setup.bundlePath,
);
expect(patched.ok).toBe(false);

const tools = makeToolSet(setup, "lead", "S-lead");
const result: any = await tools.spawnAgent!.execute({
role: "worker",
tag: "backend",
goalSummary: "Implement T-1",
stepId: "T-1",
initialMessage: VALID_BRIEF,
});
expect(result.ok).toBe(false);
expect(result.error).toBe("plan_not_approved");
expect(setup.chat.createSession).not.toHaveBeenCalled();
});

it.each([
["codex", { codexSandbox: "danger-full-access", codexApprovalPolicy: "never", codexConfigSource: "flags" }],
["cursor", { cursorModeId: "full-auto" }],
Expand Down Expand Up @@ -500,6 +534,26 @@ describe("requestPlanApproval and model routing tools", () => {
const manifest = setup.svc.getManifestForRun(setup.runId)!;
expect(manifest.modelRouting.byRoleTag?.["worker:web-ui"]).toEqual(selection);
});

it.each([
["Not approved — don't start yet", "decline"],
["No, don't proceed with this plan", "none"],
["Please revise before we proceed", "decline"],
] as const)("does not treat rejection text %j as approval when decision is %s", async (answer, decision) => {
setup = await setupWithRun("lead");
const onAskUser = vi.fn(async () => ({ answer, decision }));
const tools = makeToolSet(setup, "lead", "S-lead", {
universal: { permissionMode: "full-auto", onAskUser },
});
const result: any = await tools.requestPlanApproval!.execute({
planSummary: VALID_APPROVAL_PLAN,
});
expect(result.ok).toBe(false);
expect(result.error).toBe("plan_rejected");
const manifest = setup.svc.getManifestForRun(setup.runId)!;
expect(manifest.currentPhase).toBe("planning");
expect(manifest.leadState.planApprovedAt).toBeUndefined();
});
});

describe("orchestration heartbeats", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -744,9 +744,10 @@ function createRequestPlanApprovalTool(
const result = typeof response === "string"
? { answer: response, decision: undefined as string | undefined }
: response;
// Require an explicit approval decision — do not infer approval from free-text
// answers. Substring regexes false-positive on rejections like "Not approved".
const approved = result.decision === "accept"
|| result.decision === "accept_for_session"
|| /\b(approve|approved|yes|start|proceed)\b/i.test(result.answer ?? "");
|| result.decision === "accept_for_session";
if (!approved) {
return {
ok: false as const,
Expand Down
209 changes: 206 additions & 3 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,9 +1103,19 @@ function createMockSessionService() {
});
}),
get: vi.fn((sessionId: string) => sessions.get(sessionId) ?? null),
list: vi.fn((_opts?: any) =>
Array.from(sessions.values()),
),
list: vi.fn((opts?: any) => {
let rows = Array.from(sessions.values());
if (typeof opts?.laneId === "string") {
rows = rows.filter((row) => row.laneId === opts.laneId);
}
if (typeof opts?.status === "string") {
rows = rows.filter((row) => row.status === opts.status);
}
rows = rows.sort((a, b) => String(b.startedAt ?? "").localeCompare(String(a.startedAt ?? "")));
if (opts?.limit === null) return rows;
const limit = typeof opts?.limit === "number" ? opts.limit : 200;
return rows.slice(0, limit);
}),
reopen: vi.fn((sessionId: string) => {
const row = sessions.get(sessionId);
if (row) {
Expand Down Expand Up @@ -2830,6 +2840,199 @@ describe("createAgentChatService", () => {
}
});

it("repoints orchestration bundle path when lane placement changes", async () => {
const { orchestrationService, created } = await createLoadedOrchestrationRun("S-lead-placement");
const movedWorktree = path.join(tmpRoot, "lane-vm-mirror");
fs.mkdirSync(movedWorktree, { recursive: true });
try {
const { service, laneService } = createService({
getOrchestrationService: () => orchestrationService,
});
const session = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "sonnet",
modelId: "anthropic/claude-sonnet-4-6",
interactionMode: "orchestrator-lead",
orchestrationRunId: created.runId,
orchestrationRole: "lead",
orchestrationBundlePath: created.manifest.bundlePath,
});

const lanes = await laneService.list();
const lane1 = lanes.find((entry: { id: string }) => entry.id === "lane-1");
expect(lane1).toBeTruthy();
lane1.worktreePath = movedWorktree;
vi.mocked(laneService.getLaneBaseAndBranch).mockImplementation((nextLaneId: string) => {
const lane = lanes.find((entry: { id: string }) => entry.id === nextLaneId);
if (!lane) {
return {
baseRef: "main",
branchRef: "feature/selected",
worktreePath: tmpRoot,
laneType: "feature",
runtimePlacement: "local",
};
}
return {
baseRef: "main",
branchRef: lane.branchRef,
worktreePath: lane.worktreePath,
laneType: lane.laneType,
runtimePlacement: "local",
};
});

await service.handleLanePlacementChanged({
laneId: "lane-1",
from: "macos-vm",
to: "local",
});

const expectedBundlePath = path.join(
fs.realpathSync(movedWorktree),
".ade",
"orchestration",
created.runId,
);
expect(readPersistedChatState(session.id).orchestrationBundlePath).toBe(expectedBundlePath);
expect(orchestrationService.getBundlePathForRun(created.runId)).toBe(expectedBundlePath);
} finally {
await orchestrationService.dispose();
}
});

it("repoints persisted orchestration bundle paths for cold sessions when lane placement changes", async () => {
const { orchestrationService, created } = await createLoadedOrchestrationRun("S-cold-placement");
const movedWorktree = path.join(tmpRoot, "lane-vm-mirror-cold");
fs.mkdirSync(movedWorktree, { recursive: true });
try {
const { service, laneService } = createService({
getOrchestrationService: () => orchestrationService,
});
const session = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "sonnet",
modelId: "anthropic/claude-sonnet-4-6",
interactionMode: "orchestrator-lead",
orchestrationRunId: created.runId,
orchestrationRole: "lead",
orchestrationBundlePath: created.manifest.bundlePath,
});
await service.dispose({ sessionId: session.id });

const lanes = await laneService.list();
const lane1 = lanes.find((entry: { id: string }) => entry.id === "lane-1");
expect(lane1).toBeTruthy();
lane1.worktreePath = movedWorktree;
vi.mocked(laneService.getLaneBaseAndBranch).mockImplementation((nextLaneId: string) => {
const lane = lanes.find((entry: { id: string }) => entry.id === nextLaneId);
if (!lane) {
return {
baseRef: "main",
branchRef: "feature/selected",
worktreePath: tmpRoot,
laneType: "feature",
runtimePlacement: "local",
};
}
return {
baseRef: "main",
branchRef: lane.branchRef,
worktreePath: lane.worktreePath,
laneType: lane.laneType,
runtimePlacement: "local",
};
});

await service.handleLanePlacementChanged({
laneId: "lane-1",
from: "macos-vm",
to: "local",
});

const expectedBundlePath = path.join(
fs.realpathSync(movedWorktree),
".ade",
"orchestration",
created.runId,
);
expect(readPersistedChatState(session.id).orchestrationBundlePath).toBe(expectedBundlePath);
expect(orchestrationService.getBundlePathForRun(created.runId)).toBe(expectedBundlePath);
} finally {
await orchestrationService.dispose();
}
});

it("repoints cold orchestration bundle paths beyond the newest 500 sessions", async () => {
const { orchestrationService, created } = await createLoadedOrchestrationRun("S-cold-placement-old");
const movedWorktree = path.join(tmpRoot, "lane-vm-mirror-cold-old");
fs.mkdirSync(movedWorktree, { recursive: true });
try {
const { service, laneService } = createService({
getOrchestrationService: () => orchestrationService,
});
const session = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "sonnet",
modelId: "anthropic/claude-sonnet-4-6",
interactionMode: "orchestrator-lead",
orchestrationRunId: created.runId,
orchestrationRole: "lead",
orchestrationBundlePath: created.manifest.bundlePath,
});
await service.dispose({ sessionId: session.id });
const coldRow = mockState.sessions.get(session.id);
if (!coldRow) throw new Error("expected cold session row");
coldRow.startedAt = "2020-01-01T00:00:00.000Z";
for (let i = 0; i < 501; i++) {
mockState.sessions.set(`S-newer-${i}`, {
...coldRow,
id: `S-newer-${i}`,
title: `Newer session ${i}`,
toolType: "codex-chat",
status: "ended",
startedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(),
endedAt: new Date(Date.UTC(2026, 0, 1, 0, 1, i)).toISOString(),
});
}

const lanes = await laneService.list();
const lane1 = lanes.find((entry: { id: string }) => entry.id === "lane-1");
expect(lane1).toBeTruthy();
lane1.worktreePath = movedWorktree;
vi.mocked(laneService.getLaneBaseAndBranch).mockImplementation((nextLaneId: string) => {
const lane = lanes.find((entry: { id: string }) => entry.id === nextLaneId);
return {
baseRef: "main",
branchRef: lane?.branchRef ?? "feature/selected",
worktreePath: lane?.worktreePath ?? tmpRoot,
laneType: lane?.laneType ?? "feature",
runtimePlacement: "local",
};
});

await service.handleLanePlacementChanged({
laneId: "lane-1",
from: "macos-vm",
to: "local",
});

const expectedBundlePath = path.join(
fs.realpathSync(movedWorktree),
".ade",
"orchestration",
created.runId,
);
expect(readPersistedChatState(session.id).orchestrationBundlePath).toBe(expectedBundlePath);
expect(orchestrationService.getBundlePathForRun(created.runId)).toBe(expectedBundlePath);
} finally {
await orchestrationService.dispose();
}
});

it("attaches ADE orchestration tools to OpenCode orchestrator sessions through MCP", async () => {
vi.mocked(streamText).mockReturnValue({
fullStream: (async function* () {
Expand Down
Loading
Loading