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
1 change: 1 addition & 0 deletions apps/ade-cli/src/headlessLinearServices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ describe("headlessLinearServices", () => {
const session = await services.agentChatService.createSession({ laneId: "lane-1" });

expect(await services.agentChatService.getCtoAttention()).toEqual({
status: "idle",
awaitingInput: false,
since: null,
});
Expand Down
5 changes: 3 additions & 2 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
GitHubRepoRef,
GitHubRateLimitState,
GitHubStatus,
CtoAttentionState,
} from "../../desktop/src/shared/types";
import type {
GithubService,
Expand Down Expand Up @@ -188,7 +189,7 @@ type HeadlessLinearServices = {
sessionId: string,
) => Promise<Record<string, unknown> | null>;
/** Mirrors the desktop chat service so `cto_state.getAttention` resolves headlessly. */
getCtoAttention: () => Promise<{ awaitingInput: boolean; since: string | null }>;
getCtoAttention: () => Promise<CtoAttentionState>;
getChatTranscript: (args: {
sessionId: string;
limit?: number;
Expand Down Expand Up @@ -2707,7 +2708,7 @@ function createHeadlessAgentChatService(
// `ade actions run cto_state.getAttention` throws a TypeError. Headless
// sessions never block on user input — there is no turn loop to block —
// so "not waiting" is the truthful answer, not a placeholder.
return { awaitingInput: false, since: null };
return { status: "idle", awaitingInput: false, since: null };
},
async getChatTranscript({
sessionId,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1863,7 +1863,7 @@ function buildCtoStateDomainService(runtime: AdeRuntime): OpaqueService | null {
*/
getAttention: async (): Promise<CtoAttentionState> =>
(await runtime.agentChatService?.getCtoAttention())
?? { awaitingInput: false, since: null },
?? { status: "unknown", awaitingInput: false, since: null },
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
AutomationRunListArgs,
GitPullArgs,
OperatorNavigationSuggestion,
PtyCreateArgs,
SessionSettleOverride,
SessionWakeReason,
TestRunSummary,
Expand Down Expand Up @@ -62,7 +63,7 @@ export interface CtoOperatorToolDeps {
getLogTail: (args: { runId: string; maxBytes?: number }) => string;
} | null;
ptyService?: {
create: (args: { laneId: string; title?: string; cols?: number; rows?: number; tracked?: boolean; toolType?: "shell"; startupCommand?: string }) => Promise<{ ptyId: string; sessionId: string }>;
create: (args: PtyCreateArgs) => Promise<{ ptyId: string; sessionId: string }>;
} | null;
automationService?: {
list: () => AutomationRuleSummary[];
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8041,7 +8041,7 @@ describe("createAgentChatService", () => {
const before = sessionService.list({}).length;
const attention = await service.getCtoAttention();

expect(attention).toEqual({ awaitingInput: false, since: null });
expect(attention).toEqual({ status: "idle", awaitingInput: false, since: null });
// The invariant that matters: drawing a badge must not materialize a
// lane and a chat session as a side effect.
expect(sessionService.list({}).length).toBe(before);
Expand All @@ -8054,7 +8054,7 @@ describe("createAgentChatService", () => {
const { service, sessionService } = createService({ ctoStateService, ctoMemoryService });
const session = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" });

expect(await service.getCtoAttention()).toEqual({ awaitingInput: false, since: null });
expect(await service.getCtoAttention()).toEqual({ status: "idle", awaitingInput: false, since: null });

// `ade chat ask` raises a hand on the backing session row — a separate
// signal from the chat-level `awaitingInput` waiter, and the one a
Expand All @@ -8064,6 +8064,7 @@ describe("createAgentChatService", () => {
row.attentionRequestedAt = new Date().toISOString();
const attention = await service.getCtoAttention();

expect(attention.status).toBe("awaiting-input");
expect(attention.awaitingInput).toBe(true);
expect(attention.since).toBeTruthy();

Expand All @@ -8083,11 +8084,28 @@ describe("createAgentChatService", () => {
row.attentionRequestedAt = null;
const attention = await service.getCtoAttention();

expect(attention.status).toBe("idle");
expect(attention.awaitingInput).toBe(false);
expect(attention.since).toBeNull();

db.close();
});

it("reports unknown instead of falsely clearing when the session scan fails", async () => {
const { db, ctoStateService, ctoMemoryService } = await createCtoServices();
const { service, sessionService } = createService({ ctoStateService, ctoMemoryService });
vi.spyOn(sessionService, "list").mockImplementationOnce(() => {
throw new Error("temporary session store failure");
});

await expect(service.getCtoAttention()).resolves.toEqual({
status: "unknown",
awaitingInput: false,
since: null,
});

db.close();
});
});
});

Expand Down
70 changes: 33 additions & 37 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import { z, type ZodType } from "zod";
import { buildClaudeV2MessageAsync, inferAttachmentMediaType } from "./buildClaudeV2Message";
import { listPromptStashAttachmentPaths } from "./promptStashService";
import { ClaudeInputPump } from "./claudeInputPump";
import {
normalizeClaudeInterruptReceipt,
normalizeClaudeRewindSkippedLinks,
} from "./claudeSdkCompat";
import {
createClaudeStructuredActivityState,
finalizeClaudeStructuredActivities,
Expand Down Expand Up @@ -8384,12 +8388,7 @@ export function createAgentChatService(args: {
reuseExisting,
permissionMode: "full-auto",
}),
// The cast covers one remaining structural gap: `CtoOperatorToolDeps`
// restates `ptyService.create` with optional cols/rows/title while the real
// `PtyCreateArgs` requires them (the pty service clamps to 80x24). Harmless
// while these tool bodies were unreachable; now that they execute, it is
// worth reconciling — tracked as a follow-up rather than widened blind here.
} as Parameters<typeof createCtoOperatorTools>[0];
};
};

/**
Expand Down Expand Up @@ -13262,12 +13261,7 @@ export function createAgentChatService(args: {
stopMode: AgentChatStopMode = "stop_and_clear",
): void => {
if (!response) return;
const stillQueuedUuids = (Array.isArray(response.still_queued) ? response.still_queued : [])
.filter((uuid): uuid is string => typeof uuid === "string" && uuid.trim().length > 0)
.map((uuid) => uuid.trim());
const cancelledUuids = (Array.isArray(response.cancelled) ? response.cancelled : [])
.filter((uuid): uuid is string => typeof uuid === "string" && uuid.trim().length > 0)
.map((uuid) => uuid.trim());
const { stillQueuedUuids, cancelledUuids } = normalizeClaudeInterruptReceipt(response);
if (!stillQueuedUuids.length && !cancelledUuids.length) return;
emitChatEvent(managed, {
type: "interrupt_receipt",
Expand Down Expand Up @@ -15685,14 +15679,6 @@ export function createAgentChatService(args: {
return lease;
};

const ensureOrchestrationHttpMcpServer = (
managed: ManagedChatSession,
): Promise<HttpMcpLease | null> => ensureHttpMcpServer(managed, "orchestration");

const ensureCtoHttpMcpServer = (
managed: ManagedChatSession,
): Promise<HttpMcpLease | null> => ensureHttpMcpServer(managed, "cto");

/**
* Resolves every tool set that has a live HTTP lease, in table order. The
* per-SDK config shapes differ (record-of-http, record-of-remote, array), so
Expand Down Expand Up @@ -36697,7 +36683,9 @@ export function createAgentChatService(args: {
});
}
}
const preserveQueryForQueuedMessages = (interruptResponse?.still_queued?.length ?? 0) > 0;
const normalizedInterrupt = normalizeClaudeInterruptReceipt(interruptResponse);
const preserveQueryForQueuedMessages = normalizedInterrupt.stillQueuedUuids.length > 0;
const providerCancelledUuids = normalizedInterrupt.cancelledUuids;
if (!preserveQueryForQueuedMessages) {
// Invalidate the idle reader and any already-issued `next()` promise as
// part of the same reset. Clearing only query/inputPump lets that stale
Expand All @@ -36706,8 +36694,8 @@ export function createAgentChatService(args: {
}
if (mode === "stop_and_clear") {
const localQueuedCount = localQueuedForRecovery.length;
result.cancelledQueuedCount = localQueuedCount + (interruptResponse?.cancelled?.length ?? 0);
const providerCancelledSteers = (interruptResponse?.cancelled ?? []).flatMap((uuid) => {
result.cancelledQueuedCount = localQueuedCount + providerCancelledUuids.length;
const providerCancelledSteers = providerCancelledUuids.flatMap((uuid) => {
const steer = knownQueuedMessagesAtInterrupt.get(uuid)?.steer;
return steer ? [steer] : [];
});
Expand Down Expand Up @@ -38568,21 +38556,26 @@ export function createAgentChatService(args: {
* ask`), which is a separate signal from `awaitingInput`.
*/
const getCtoAttention = async (): Promise<CtoAttentionState> => {
const idle: CtoAttentionState = { awaitingInput: false, since: null };
const idle: CtoAttentionState = { status: "idle", awaitingInput: false, since: null };
try {
const cto = (await listIdentitySessions("cto"))[0];
if (!cto) return idle;
const handRaisedAt = sessionService.get(cto.sessionId)?.attentionRequestedAt ?? null;
const awaitingInput = Boolean(cto.awaitingInput || cto.pendingInputItemId || handRaisedAt);
if (!awaitingInput) return idle;
return { awaitingInput: true, since: handRaisedAt ?? cto.lastActivityAt ?? null };
return {
status: "awaiting-input",
awaitingInput: true,
since: handRaisedAt ?? cto.lastActivityAt ?? null,
};
} catch (error) {
// A probe failure must not break the caller; the renderer keeps its last
// known state rather than falsely clearing a pending question.
// A probe failure must not break the caller or masquerade as idle. Every
// transport forwards `unknown` so clients can retain their last known
// badge state rather than falsely clearing a pending question.
logger.warn("agent_chat.cto_attention_probe_failed", {
error: error instanceof Error ? error.message : String(error),
});
return idle;
return { status: "unknown", awaitingInput: false, since: null };
}
};

Expand Down Expand Up @@ -41856,15 +41849,18 @@ export function createAgentChatService(args: {
const normalizeClaudeRewindFilesResult = (
result: ClaudeRewindFilesResult,
dryRun: boolean,
): AgentChatRewindFilesResult => ({
canRewind: result.canRewind === true,
...(typeof result.error === "string" && result.error.trim().length ? { error: result.error.trim() } : {}),
filesChanged: Array.isArray(result.filesChanged) ? result.filesChanged.filter((file): file is string => typeof file === "string" && file.trim().length > 0) : [],
insertions: Number.isFinite(result.insertions) ? Math.max(0, result.insertions ?? 0) : 0,
deletions: Number.isFinite(result.deletions) ? Math.max(0, result.deletions ?? 0) : 0,
...(Number.isFinite(result.skippedLinks) ? { skippedLinks: Math.max(0, result.skippedLinks ?? 0) } : {}),
dryRun,
});
): AgentChatRewindFilesResult => {
const skippedLinks = normalizeClaudeRewindSkippedLinks(result);
return {
canRewind: result.canRewind === true,
...(typeof result.error === "string" && result.error.trim().length ? { error: result.error.trim() } : {}),
filesChanged: Array.isArray(result.filesChanged) ? result.filesChanged.filter((file): file is string => typeof file === "string" && file.trim().length > 0) : [],
insertions: Number.isFinite(result.insertions) ? Math.max(0, result.insertions ?? 0) : 0,
deletions: Number.isFinite(result.deletions) ? Math.max(0, result.deletions ?? 0) : 0,
...(skippedLinks != null ? { skippedLinks } : {}),
dryRun,
};
};

type CodexRewindFileRestore = {
path: string;
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/main/services/chat/claudeSdkCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type ClaudeInterruptReceipt = {
stillQueuedUuids: string[];
cancelledUuids: string[];
};

function normalizedStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => entry.trim());
}

/** Normalize fields that have moved in and out of the published Claude SDK type. */
export function normalizeClaudeInterruptReceipt(value: unknown): ClaudeInterruptReceipt {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { stillQueuedUuids: [], cancelledUuids: [] };
}
const record = value as Record<string, unknown>;
return {
stillQueuedUuids: normalizedStringList(record.still_queued),
cancelledUuids: normalizedStringList(record.cancelled),
};
}

/** Read the newer rewind result field without coupling callers to one SDK declaration. */
export function normalizeClaudeRewindSkippedLinks(value: unknown): number | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const skippedLinks = (value as Record<string, unknown>).skippedLinks;
return typeof skippedLinks === "number" && Number.isFinite(skippedLinks)
? Math.max(0, skippedLinks)
: null;
}
56 changes: 2 additions & 54 deletions apps/desktop/src/main/services/cto/ctoPromptContent.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,3 @@
import { createCtoOperatorTools, type CtoOperatorToolDeps } from "../ai/tools/ctoOperatorTools";

type ToolPreviewDeps = CtoOperatorToolDeps & {
previewSessionToolNames: (args: { provider?: string; model?: string; identityKey?: string }) => string[];
};

const previewDeps = {
currentSessionId: "preview-cto-session",
defaultLaneId: "preview-lane",
defaultModelId: null,
defaultReasoningEffort: null,
resolveExecutionLane: async () => "preview-lane",
laneService: null,
prService: null,
fileService: null,
testService: null,
ptyService: null,
automationService: null,
gitService: null,
conflictService: null,
steerChat: undefined,
cancelSteer: undefined,
handoffChat: undefined,
listSubagents: undefined,
approveToolUse: undefined,
issueTracker: null,
ctoStateService: null,
listChats: async () => [],
getChatStatus: async () => null,
getChatTranscript: async () => null,
createChat: async () => ({ id: "preview-chat" }),
updateChatSession: async () => undefined,
sendChatMessage: async () => undefined,
interruptChat: async () => undefined,
sessionService: { updateMeta: async () => undefined },
ensureCtoSession: async () => ({ id: "preview-cto-session", laneId: "preview-lane" }),
previewSessionToolNames: () => [],
} as unknown as ToolPreviewDeps;

/**
* Onboarding step id that records the CTO's opening turn. Not a user-facing
* setup step — it lives in the same list so it is persisted and so
Expand All @@ -57,24 +18,11 @@ export const CTO_INTRO_PROMPT = [
"Keep it short.",
].join(" ");

function compactDescription(description: string): string {
return description
.replace(/\s+/g, " ")
.trim()
.replace(/\.$/, "");
}

export function buildCtoCapabilityManifest(): string {
const tools = createCtoOperatorTools(previewDeps);
const lines = Object.entries(tools)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, definition]) => ` ${name} — ${compactDescription(definition.description)}`);
return [
"# ADE Operator Tools (generated reference)",
"",
"Generated from ctoOperatorTools.ts so prompt capability docs stay aligned with the registered tool surface.",
"# ADE operator tools",
"",
...lines,
"Use the registered ADE operator tool schemas as the authoritative capability reference. Their schemas are always loaded for CTO sessions, so their descriptions are not duplicated here.",
"",
"# Operating Rules",
"",
Expand Down
19 changes: 10 additions & 9 deletions apps/desktop/src/main/services/cto/ctoState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,10 @@ describe("ctoStateService", () => {
expect(preview.sections[4]?.content).toContain("Model Selection");
expect(preview.sections[4]?.content).toContain("ade actions run <domain.action>");
expect(preview.sections[4]?.content).toContain("bundled `ade-*` skills");
// Capabilities section: organized tool reference with descriptions
expect(preview.sections[5]?.content).toContain("ADE Operator Tools");
expect(preview.sections[5]?.content).toContain("listLanes");
// Capabilities section: schema authority plus cross-tool operating rules
expect(preview.sections[5]?.content).toContain("ADE operator tools");
expect(preview.sections[5]?.content).toContain("registered ADE operator tool schemas");
expect(preview.sections[5]?.content).not.toContain("listLanes —");
expect(preview.sections[5]?.content).toContain("UI navigation is suggestion-only.");
expect(preview.prompt).toContain("Immutable ADE doctrine");
expect(preview.prompt).toContain("Selected personality overlay");
Expand Down Expand Up @@ -310,9 +311,8 @@ describe("ctoStateService", () => {
fixture.db.close();
});

// The capability manifest is the CTO's live lane-routing lever: its operator
// tool bodies are not registered on a running session, so the prompt is what
// actually steers where CTO-launched work lands. It used to instruct
// The capability manifest keeps the cross-tool operating rules in one place.
// It used to instruct
// "always default laneId to the CTO's current lane" — the CTO's lane is the
// project's primary lane, so every agent it launched ran against the primary
// worktree.
Expand All @@ -324,11 +324,12 @@ describe("ctoStateService", () => {
expect(manifest).toMatch(/primary lane/i);
});

it("generates the manifest from the registered operator tool surface", () => {
it("does not duplicate registered tool descriptions in the manifest", () => {
const manifest = buildCtoCapabilityManifest();

expect(manifest).toContain("spawnChat");
expect(manifest).toContain("createLane");
expect(manifest).toContain("registered ADE operator tool schemas");
expect(manifest).not.toContain("spawnChat —");
expect(manifest).not.toContain("createLane —");
expect(manifest).toContain("# Operating Rules");
});
});
Loading