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
6 changes: 5 additions & 1 deletion apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2934,11 +2934,15 @@ async function runCtoOperatorBridgeTool(
getChatTranscript: agentChatService.getChatTranscript,
createChat: agentChatService.createSession,
updateChatSession: agentChatService.updateSession,
previewSessionToolNames: agentChatService.previewSessionToolNames,
sendChatMessage: agentChatService.sendMessage,
interruptChat: async (args) => {
await agentChatService.interrupt(args);
},
steerChat: ({ sessionId, instruction }) => agentChatService.steer({ sessionId, text: instruction }),
cancelSteer: ({ sessionId, steerId }) => agentChatService.cancelSteer({ sessionId, steerId }),
listSubagents: ({ sessionId }) => agentChatService.listSubagents({ sessionId }),
approveToolUse: ({ sessionId, toolUseId, decision }) =>
agentChatService.approveToolUse({ sessionId, itemId: toolUseId, decision }),
ensureCtoSession: async ({ laneId, modelId, reasoningEffort, reuseExisting }) =>
agentChatService.ensureIdentitySession({
identityKey: "cto",
Expand Down
41 changes: 41 additions & 0 deletions apps/ade-cli/src/headlessLinearServices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,47 @@ describe("headlessLinearServices", () => {
services.dispose();
});

it("answers the CTO operator tool chat surface instead of throwing TypeError", async () => {
// The CTO operator tools and the chat.* sync commands call these on
// `runtime.agentChatService` unconditionally — the call sites only guard a
// null service, not a missing method. A stub missing any of them turns
// `ade actions run` / a phone tap into "x is not a function".
const services = createHeadlessLinearServices(createDeps());
const session = await services.agentChatService.createSession({ laneId: "lane-1" });

expect(await services.agentChatService.getCtoAttention()).toEqual({
awaitingInput: false,
since: null,
});
expect(await services.agentChatService.listSubagents({ sessionId: session.id })).toEqual([]);

// Headless steers are delivered immediately (queued: false), so there is
// never a queued steer to pull back: a plain cancel is a no-op, and a
// requireQueued cancel must fail the way the desktop service does.
await expect(
services.agentChatService.cancelSteer({ sessionId: session.id, steerId: "steer-missing" }),
).resolves.toBeUndefined();
await expect(
services.agentChatService.cancelSteer({
sessionId: session.id,
steerId: "steer-missing",
requireQueued: true,
}),
).rejects.toThrow(/no longer queued/);

// Headless raises no approvals, so approving one must be an honest error
// rather than a silent success the caller would misread as "approved".
await expect(
services.agentChatService.approveToolUse({
sessionId: session.id,
itemId: "tool-1",
decision: "accept",
}),
).rejects.toThrow(/No pending approval/);

services.dispose();
});

it("dispose removes session and transcript data", async () => {
const services = createHeadlessLinearServices(createDeps());

Expand Down
49 changes: 49 additions & 0 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,25 @@ type HeadlessLinearServices = {
reason?: "queue_full";
}>;
interrupt: (args: { sessionId: string }) => Promise<void>;
/**
* Mirrors the desktop chat service so the CTO operator tools
* (`cancelSteer`) and the `chat.cancelSteer` sync command resolve
* headlessly instead of throwing "not a function".
*/
cancelSteer: (args: {
sessionId: string;
steerId: string;
requireQueued?: boolean;
}) => Promise<void>;
/** Headless runs spawn no sub-agents; kept so `chat.listSubagents` answers. */
listSubagents: (args: { sessionId: string }) => Promise<never[]>;
/** Headless runs never raise approval requests; kept so callers get a real error. */
approveToolUse: (args: {
sessionId: string;
itemId: string;
decision: "accept" | "accept_for_session" | "decline" | "cancel";
responseText?: string | null;
}) => Promise<void>;
resumeSession: (args: {
sessionId: string;
}) => Promise<HeadlessAgentChatSession>;
Expand Down Expand Up @@ -2090,6 +2109,36 @@ function createHeadlessAgentChatService(
async interrupt(args: { sessionId: string }) {
touchSession(args.sessionId);
},
async cancelSteer(args: {
sessionId: string;
steerId: string;
requireQueued?: boolean;
}) {
// `steerMessage` appends immediately and reports `queued: false`, so a
// headless steer is never sitting in a queue to pull back. This mirrors
// the desktop service's "no live runtime" branch exactly: reject only
// when the caller demanded the steer still be queued.
if (args.requireQueued) throw new Error("This message is no longer queued.");
touchSession(args.sessionId);
},
async listSubagents(_args: { sessionId: string }) {
// No turn loop, so no sub-agents are ever tracked. Empty is the truthful
// answer, not a placeholder.
return [] as never[];
},
async approveToolUse(args: {
sessionId: string;
itemId: string;
decision: "accept" | "accept_for_session" | "decline" | "cancel";
responseText?: string | null;
}) {
// Headless sessions never emit approval requests, so any itemId a caller
// passes is unresolvable. Throw rather than silently succeed — a caller
// that believes it approved a tool use would be wrong.
throw new Error(
`No pending approval found for item '${args.itemId}' (headless chat runtime raises no approvals).`,
);
},
async resumeSession(args: { sessionId: string }) {
return ensureSession({
sessionId: args.sessionId,
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4814,6 +4814,7 @@ describe("CTO-gated Linear sync commands", () => {
"cto.completeLinearMobileOAuth",
"cto.setLinearToken",
"cto.clearLinearToken",
"cto.getAttention",
"session.settleSessions",
"session.unsettleSessions",
"session.setSettleOverride",
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4779,6 +4779,13 @@ function registerCtoRemoteCommands({ args, register }: RemoteCommandRegistration
const recentLimit = asOptionalNumber(payload.recentLimit);
return ctoStateService.getSnapshot(recentLimit ?? 20);
});
register("cto.getAttention", { viewerAllowed: true }, async () => {
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
// Strictly read-only: `getCtoAttention` never calls ensureIdentitySession,
// so a phone drawing a badge cannot materialize a primary lane and a CTO
// chat session as a side effect. Returns CtoAttentionState verbatim.
return agentChatService.getCtoAttention();
});
register("cto.getMemory", { viewerAllowed: true }, async () => {
const ctoMemoryService = requireService(args.ctoMemoryService, "CTO memory service not available.");
// Returns the exact CtoMemorySnapshot shape the iOS client decodes:
Expand Down
153 changes: 146 additions & 7 deletions apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,12 @@ function buildDeps(overrides: Partial<CtoOperatorToolDeps> = {}): CtoOperatorToo
truncated: false,
totalEntries: 1,
}),
steerChat: vi.fn().mockResolvedValue({ steerId: "steer-1", queued: true }),
cancelSteer: vi.fn().mockResolvedValue(undefined),
listSubagents: vi.fn().mockResolvedValue([]),
approveToolUse: vi.fn().mockResolvedValue(undefined),
createChat: vi.fn().mockResolvedValue(baseSession),
updateChatSession: vi.fn().mockResolvedValue(baseSession),
previewSessionToolNames: vi.fn(() => [
"prRefreshIssueInventory",
"prGetReviewComments",
"prRerunFailedChecks",
"prReplyToReviewThread",
"prResolveReviewThread",
]),
sendChatMessage: vi.fn().mockResolvedValue(undefined),
interruptChat: vi.fn().mockResolvedValue(undefined),
ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-session" }),
Expand Down Expand Up @@ -182,6 +179,148 @@ describe("createCtoOperatorTools", () => {
expect(gitService.stashPop).not.toHaveBeenCalled();
});

// The CTO session is pinned to the project's primary lane, so `defaultLaneId`
// IS primary. A mutating git call with the lane omitted used to mean "write to
// the primary worktree" — the thing lanes exist to prevent.
describe("mutating git tools refuse to default the lane", () => {
const MUTATIONS: Array<[string, Record<string, unknown>]> = [
["gitCommit", { message: "wip" }],
["gitPush", {}],
["gitPull", {}],
["gitUndoLastHeadChange", {}],
["gitRedoLastHeadChange", {}],
["gitStashPush", {}],
["gitStashPop", {}],
["gitCheckoutBranch", { branch: "feature" }],
["gitRebaseContinue", {}],
["gitRebaseAbort", {}],
["gitMergeAbort", {}],
];

it.each(MUTATIONS)("%s errors instead of falling back to the CTO's lane", async (toolName, args) => {
const gitService = {
commit: vi.fn(), push: vi.fn(), pull: vi.fn(),
undoLastHeadChange: vi.fn(), redoLastHeadChange: vi.fn(),
stashPush: vi.fn(), stashPop: vi.fn(), listStashes: vi.fn().mockResolvedValue([]),
checkoutBranch: vi.fn(), rebaseContinue: vi.fn(), rebaseAbort: vi.fn(), mergeAbort: vi.fn(),
};
const deps = buildDeps({ gitService: gitService as any });
const tools = createCtoOperatorTools(deps);

const result = await (tools[toolName] as any).execute(args);

expect(result.success).toBe(false);
expect(result.error).toContain("needs an explicit laneId");
// Nothing may have touched git — especially not on the default lane.
for (const [name, call] of Object.entries(gitService)) {
if (name === "listStashes") continue; // read-only lookup inside gitStashPop
expect(call, `${name} must not run without an explicit lane`).not.toHaveBeenCalled();
}
});

it("still runs the mutation when the lane is named", async () => {
const gitService = { commit: vi.fn().mockResolvedValue({ operationId: "commit-1" }) };
const deps = buildDeps({ gitService: gitService as any });
const tools = createCtoOperatorTools(deps);

const result = await (tools.gitCommit as any).execute({ laneId: "lane-9", message: "real work" });

expect(gitService.commit).toHaveBeenCalledWith(
expect.objectContaining({ laneId: "lane-9", message: "real work" }),
);
expect(result).toMatchObject({ success: true });
});

it("keeps defaulting the lane for read-only git inspection", async () => {
const gitService = { getSyncStatus: vi.fn().mockResolvedValue({ branch: "main" }) };
const deps = buildDeps({ gitService: gitService as any });
const tools = createCtoOperatorTools(deps);

await (tools.gitStatus as any).execute({});

// Reading the primary lane is normal supervision, so this default stays.
expect(gitService.getSyncStatus).toHaveBeenCalledWith({ laneId: "lane-1" });
});
});

// These four were wired to `undefined`, so they were advertised in the prompt
// manifest and registered on every transport but could only ever answer
// "not available" — the same defect the live-registration work exists to fix.
describe("supervision tools reach their real implementations", () => {
it("steerChat forwards the instruction as the steer text", async () => {
const deps = buildDeps();
const tools = createCtoOperatorTools(deps);

const result = await (tools.steerChat as any).execute({ sessionId: "chat-7", instruction: "focus on the failing shard" });

expect(deps.steerChat).toHaveBeenCalledWith({ sessionId: "chat-7", instruction: "focus on the failing shard" });
expect(result).toMatchObject({ success: true });
expect(result.error).toBeUndefined();
});

it("cancelSteer requires the steerId the underlying API needs", async () => {
const deps = buildDeps();
const tools = createCtoOperatorTools(deps);

const result = await (tools.cancelSteer as any).execute({ sessionId: "chat-7", steerId: "steer-1" });

expect(deps.cancelSteer).toHaveBeenCalledWith({ sessionId: "chat-7", steerId: "steer-1" });
expect(result).toMatchObject({ success: true, sessionId: "chat-7" });
});

it("approveToolUse maps toolUseId onto the service's itemId", async () => {
const deps = buildDeps();
const tools = createCtoOperatorTools(deps);

const result = await (tools.approveToolUse as any).execute({
sessionId: "chat-7", toolUseId: "item-3", decision: "accept",
});

expect(deps.approveToolUse).toHaveBeenCalledWith({
sessionId: "chat-7", toolUseId: "item-3", decision: "accept",
});
expect(result).toMatchObject({ success: true });
});

it("no longer exposes handoffChat", () => {
// Handoff targeted "a different agent identity" — a subsystem that was
// removed; AgentChatIdentityKey is now just "cto". Advertising it was
// advertising a capability that could not exist.
const tools = createCtoOperatorTools(buildDeps());

expect(Object.keys(tools)).not.toContain("handoffChat");
expect(Object.keys(tools)).toContain("spawnChat");
});
});

it("createTerminal passes explicit pty dimensions instead of relying on the clamp", async () => {
const ptyService = { create: vi.fn().mockResolvedValue({ sessionId: "pty-1" }) };
const deps = buildDeps({ ptyService: ptyService as any });
const tools = createCtoOperatorTools(deps);

await (tools.createTerminal as any).execute({ laneId: "lane-2" });

// PtyCreateArgs requires these; omitting them was silently clamped inside
// the pty service, which was invisible until these bodies started executing.
expect(ptyService.create).toHaveBeenCalledWith(expect.objectContaining({
laneId: "lane-2", cols: 100, rows: 30, title: "CTO terminal",
}));
});

it("rejects a missing lane at the schema layer, not just at execute()", () => {
const tools = createCtoOperatorTools(buildDeps());

// The model reads the schema as the contract. Enforcement that lives only
// in execute() lets it call, fail, and burn a turn.
for (const name of ["gitCommit", "gitPush", "gitPull", "gitUndoLastHeadChange", "gitMergeAbort"]) {
const parsed = (tools[name] as any).inputSchema.safeParse({ message: "x", branch: "y" });
expect(parsed.success, `${name} must require laneId`).toBe(false);
}

// Read-only inspection still accepts an omitted lane.
expect((tools.gitStatus as any).inputSchema.safeParse({}).success).toBe(true);
});

// ── Chat tools ──────────────────────────────────────────────────

describe("chat tools", () => {
Expand Down
Loading