From d6e38751346d6e95515f535dcd864d4902c27526 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:42:54 -0400 Subject: [PATCH 1/9] fix(work): synchronize automatic lane identity --- CHANGELOG.md | 4 ++ .../sync/syncRemoteCommandService.test.ts | 2 + .../services/sync/syncRemoteCommandService.ts | 2 + apps/ade-cli/src/tuiClient/remoteLauncher.ts | 1 + .../main/services/chat/agentChatService.ts | 14 ++++- .../main/services/lanes/laneService.test.ts | 10 ++++ .../src/main/services/lanes/laneService.ts | 18 ++++++ .../sessions/chatSessionProjection.ts | 2 + .../app/toast/useLaneEventToasts.ts | 2 +- .../components/chat/AgentChatPane.test.tsx | 20 +++++++ .../components/chat/AgentChatPane.tsx | 56 +++++++++++++------ .../components/terminals/LaneNamingLabel.tsx | 20 +++++++ .../components/terminals/SessionCard.test.tsx | 43 ++++++++++++-- .../components/terminals/SessionCard.tsx | 17 +++--- .../terminals/SessionListPane.test.tsx | 15 +++++ .../components/terminals/SessionListPane.tsx | 20 +++++-- .../terminals/SessionStatusSlot.tsx | 15 +++-- apps/desktop/src/renderer/index.css | 25 +++++++++ apps/desktop/src/renderer/lib/sessions.ts | 2 + .../src/renderer/state/laneNamingStore.ts | 10 ++-- apps/desktop/src/shared/types/chat.ts | 4 ++ apps/desktop/src/shared/types/lanes.ts | 6 +- apps/desktop/src/shared/types/sessions.ts | 2 + docs/features/chat/README.md | 16 ++++-- docs/features/chat/composer-and-ui.md | 17 +++--- docs/features/lanes/README.md | 4 +- .../features/terminals-and-sessions/README.md | 26 ++++++--- .../terminals-and-sessions/ui-surfaces.md | 30 +++++----- 28 files changed, 310 insertions(+), 93 deletions(-) create mode 100644 apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7090424..8b7412f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Work and reliability + +- Show an animated `Naming lane…` placeholder while automatic lane identity is still resolving, reveal the deterministic fallback only on failure, refresh renamed branches live across Lanes, Git Actions, and session hover details, and keep sidebar Working durations anchored to each chat turn. + ## [1.2.46] - 2026-07-30 ### GitHub stacked pull requests diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 896c84352..d223f9b38 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1801,6 +1801,7 @@ describe("createSyncRemoteCommandService", () => { const getSessionSummary = vi.fn().mockResolvedValue({ sessionId: "session-1", status: "active", + currentTurnStartedAt: "2026-07-31T12:00:00.000Z", awaitingInput: true, pendingInputItemId: "provider-question-1", orchestrationRunId: "run-1", @@ -1830,6 +1831,7 @@ describe("createSyncRemoteCommandService", () => { chatIdleSinceAt: null, pendingInputItemId: "provider-question-1", attentionSource: "provider_structured", + currentTurnStartedAt: "2026-07-31T12:00:00.000Z", orchestrationRunId: "run-1", orchestrationRole: "worker", orchestrationTag: "impl", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 76709653c..d8797b70e 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1221,6 +1221,7 @@ async function summarizeChatSessionForRemote( ...(session.capabilityMode ? { capabilityMode: session.capabilityMode } : {}), completion: session.completion ?? null, status: session.status, + currentTurnStartedAt: session.currentTurnStartedAt ?? null, idleSinceAt: session.idleSinceAt ?? null, startedAt: session.createdAt, endedAt: null, @@ -1646,6 +1647,7 @@ function projectChatOntoSession( ) { const base = { ...session, + currentTurnStartedAt: chat.currentTurnStartedAt ?? null, ...(chat.orchestrationRunId ? { orchestrationRunId: chat.orchestrationRunId, diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index 56f8d7e9c..38c7644a9 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -928,6 +928,7 @@ function coerceChatSessions(value: unknown): AgentChatSessionSummary[] { title: trimString(entry.title), goal: trimString(entry.goal), status: status as AgentChatSessionSummary["status"], + currentTurnStartedAt: trimString(entry.currentTurnStartedAt), startedAt, endedAt: trimString(entry.endedAt), lastActivityAt, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 710b31dd6..571bc5a6c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -12413,6 +12413,9 @@ export function createAgentChatService(args: { }; const setSessionActive = (managed: ManagedChatSession): void => { + if (managed.session.status !== "active") { + managed.session.currentTurnStartedAt = nowIso(); + } managed.session.status = "active"; managed.session.idleSinceAt = null; }; @@ -12422,6 +12425,7 @@ export function createAgentChatService(args: { options?: { idleSinceAt?: string | null }, ): void => { managed.session.status = "idle"; + managed.session.currentTurnStartedAt = null; if (options && "idleSinceAt" in options) { managed.session.idleSinceAt = options.idleSinceAt ?? null; } @@ -12464,6 +12468,7 @@ export function createAgentChatService(args: { const setSessionEnded = (managed: ManagedChatSession): void => { managed.session.status = "ended"; + managed.session.currentTurnStartedAt = null; managed.session.idleSinceAt = null; }; @@ -34183,7 +34188,7 @@ export function createAgentChatService(args: { } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (msg === "Droid session interrupted." || msg === "Droid session closed during setup.") { - managed.session.status = "idle"; + setSessionIdle(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); for (const ev of mapStopReasonToTerminalEvents({ stopReason: "cancelled", @@ -34244,7 +34249,7 @@ export function createAgentChatService(args: { managed.pendingReconstructionContext = null; } if (runtime.interrupted) { - managed.session.status = "idle"; + setSessionIdle(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); for (const ev of mapStopReasonToTerminalEvents({ stopReason: "cancelled", @@ -34260,7 +34265,7 @@ export function createAgentChatService(args: { await ensureDroidSessionState(managed, runtime); if (runtime.interrupted) { - managed.session.status = "idle"; + setSessionIdle(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); for (const ev of mapStopReasonToTerminalEvents({ stopReason: "cancelled", @@ -37368,6 +37373,9 @@ export function createAgentChatService(args: { ? { importedFrom: liveSession?.importedFrom ?? persisted?.importedFrom } : {}), status: liveSession?.status ?? (row.status === "running" ? "idle" : "ended"), + currentTurnStartedAt: liveSession?.status === "active" + ? liveSession.currentTurnStartedAt ?? null + : null, idleSinceAt: (liveSession?.status ?? (row.status === "running" ? "idle" : "ended")) === "idle" ? liveSession?.idleSinceAt ?? persisted?.idleSinceAt ?? null : null, diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 78beb5f08..7fff4f96e 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -807,12 +807,21 @@ describe("laneService automatic lane identity", () => { throw new Error(`Unexpected git call: ${args.join(" ")}`); }); }); + const identityUpdateBranchRefs: string[] = []; const service = createLaneService({ db, projectRoot: repoRoot, projectId: "proj-auto-identity", defaultBaseRef: "main", worktreesDir: path.join(repoRoot, "worktrees"), + onLifecycleEvent: (event) => { + if (event.type !== "lane-updated") return; + const row = db.get<{ branch_ref: string }>( + "select branch_ref from lanes where id = ?", + ["lane-child"], + ); + identityUpdateBranchRefs.push(row?.branch_ref ?? ""); + }, }); const mutation = { laneId: "lane-child", @@ -840,6 +849,7 @@ describe("laneService automatic lane identity", () => { expect(db.get("select branch_ref from lanes where id = ?", ["lane-child"])).toMatchObject({ branch_ref: "ade/naming-auto-created-lanes", }); + expect(identityUpdateBranchRefs).toEqual(["ade/naming-auto-created-lanes"]); } finally { db.close(); fs.rmSync(repoRoot, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index ea0c0241d..b5a73375a 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -5601,6 +5601,15 @@ export function createLaneService({ projectId, ]); invalidateLaneListCache(); + const reconciledLane = getLaneRow(args.laneId); + if (reconciledLane) { + broadcastLifecycleEvent({ + type: "lane-updated", + laneId: args.laneId, + laneName: reconciledLane.name, + color: reconciledLane.color, + }); + } return { laneRenameOutcome, branchRenameOutcome: "renamed", @@ -6654,6 +6663,15 @@ export function createLaneService({ throw error; } invalidateLaneListCache(); + const updated = getLaneRow(laneId); + if (updated) { + broadcastLifecycleEvent({ + type: "lane-updated", + laneId, + laneName: updated.name, + color: updated.color, + }); + } }, invalidateCache(): void { diff --git a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts index 7f6393776..03d2e4add 100644 --- a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts +++ b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts @@ -57,6 +57,7 @@ export function fallbackUnprojectedChatSession( return { ...session, runtimeState: session.pendingInputItemId ? "waiting-input" : "idle", + currentTurnStartedAt: null, chatIdleSinceAt: session.chatIdleSinceAt ?? null, }; } @@ -72,6 +73,7 @@ export function projectChatOntoSession( ): TerminalSessionSummary { const base: TerminalSessionSummary = { ...session, + currentTurnStartedAt: chat.currentTurnStartedAt ?? null, nextWakeAt: chat.nextWakeAt, chatActivityMode: chat.interactionMode === "plan" ? "planning" : null, activeBackgroundTaskCount: chat.activeBackgroundTaskCount ?? 0, diff --git a/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts b/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts index 8ca975ec2..cb6fd34e3 100644 --- a/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts +++ b/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts @@ -28,7 +28,7 @@ export function useLaneEventToasts(navigate: NavigateFunction): void { }); return; } - if (event.type === "lane-renamed") return; + if (event.type === "lane-renamed" || event.type === "lane-updated") return; showToast({ id: `lane-${event.type}-${event.laneId}`, title: event.laneName, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 5e184fb39..64fc48685 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -35,6 +35,7 @@ import { } from "../shared/ModelPicker/runtimeCatalogCache"; import { AgentChatPane, + activeTurnSessionSummaryPatch, buildParallelLaunchPrompt, cleanupChatActionsAutoOpenStorage, cleanupTransientParallelLaunchLanes, @@ -159,6 +160,25 @@ function buildSession(sessionId: string, overrides: Partial { + it("anchors a new turn without resetting the anchor for later activity or steers", () => { + const startedAt = "2026-07-31T12:00:00.000Z"; + expect(activeTurnSessionSummaryPatch(startedAt, true)).toMatchObject({ + status: "active", + lastActivityAt: startedAt, + currentTurnStartedAt: startedAt, + }); + + const activityAt = "2026-07-31T12:00:05.000Z"; + expect(activeTurnSessionSummaryPatch(activityAt, false)).toEqual({ + status: "active", + idleSinceAt: null, + awaitingInput: false, + lastActivityAt: activityAt, + }); + }); +}); + function buildPrSummary(overrides: Partial = {}): PrSummary { return { id: "pr-1", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f838beb4b..d51fe16ad 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1215,6 +1215,19 @@ export function resolveUnchangedHistoryTurnActive( ); } +export function activeTurnSessionSummaryPatch( + timestamp: string, + startsNewTurn: boolean, +): Partial { + return { + status: "active", + idleSinceAt: null, + awaitingInput: false, + lastActivityAt: timestamp, + ...(startsNewTurn ? { currentTurnStartedAt: timestamp } : {}), + }; +} + type AgentChatSessionViewCache = { events: AgentChatEventEnvelope[]; turnActive: boolean; @@ -7376,12 +7389,13 @@ export function AgentChatPane({ if (isRemoteProject && envelope.event.type === "status") { remoteDeltaArmedSessionsRef.current.add(envelope.sessionId); } - patchSessionSummary(envelope.sessionId, { - status: "active", - idleSinceAt: null, - awaitingInput: false, - lastActivityAt: envelope.timestamp, - }); + patchSessionSummary( + envelope.sessionId, + activeTurnSessionSummaryPatch( + envelope.timestamp, + envelope.event.type === "status", + ), + ); } // User messages and lifecycle edges must flush immediately so the @@ -8721,6 +8735,13 @@ export function AgentChatPane({ await refreshLanesStore().catch(() => undefined); } } + // The backend applies both the lane title and branch for single-lane + // auto naming. Refresh before unmasking the fallback so every renderer + // consumer (card hover, Lanes, and both Git Actions panes) sees the + // completed identity atomically. + if (args.backendAppliesLaneTitle && canRefreshPinnedProject(args.pin)) { + await refreshLanesStore().catch(() => undefined); + } } catch (error) { console.warn("background lane naming failed; keeping deterministic name", error); } finally { @@ -8850,11 +8871,6 @@ export function AgentChatPane({ } throw abortError; } - if (canRefreshPinnedProject(pin)) { - await refreshLanesStore().catch((refreshError: unknown) => { - console.warn("draft launch lane refresh failed", refreshError); - }); - } if (titleSettings?.enabled !== false) { startBackgroundLaneNaming({ laneId: createdLane.id, @@ -8866,6 +8882,11 @@ export function AgentChatPane({ pin, }); } + if (canRefreshPinnedProject(pin)) { + await refreshLanesStore().catch((refreshError: unknown) => { + console.warn("draft launch lane refresh failed", refreshError); + }); + } return { laneId: createdLane.id, laneName: createdLane.name, @@ -10149,13 +10170,12 @@ export function AgentChatPane({ setContextAttachments([]); } - touchSession(sessionId); - patchSessionSummary(sessionId, { - status: "active", - idleSinceAt: null, - awaitingInput: false, - lastActivityAt: new Date().toISOString(), - }); + const submittedAt = new Date().toISOString(); + touchSession(sessionId, submittedAt); + patchSessionSummary( + sessionId, + activeTurnSessionSummaryPatch(submittedAt, !turnActive), + ); const steerMessage = async (): Promise => { return await window.ade.agentChat.steer({ diff --git a/apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx b/apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx new file mode 100644 index 000000000..9bb61b19d --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx @@ -0,0 +1,20 @@ +export function LaneNamingLabel({ + laneName, + naming, +}: { + laneName: string; + naming: boolean; +}) { + if (!naming) return <>{laneName}; + + return ( + + Naming lane + + . + . + . + + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 5ae0eb677..725948c80 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -352,34 +352,38 @@ describe("SessionCard lineage", () => { }); describe("SessionCard auto-naming status", () => { - it("shows the auto-naming status in place of the preview while the lane is being named", () => { + it("masks the fallback lane name while preserving the live preview", () => { setLaneNaming("lane-1", true); render( , ); - expect(screen.getByText(/Auto-naming lane underway/i)).toBeTruthy(); - expect(screen.queryByText(/running the build/i)).toBeNull(); + expect(screen.getByLabelText("Naming lane…")).toBeTruthy(); + expect(screen.queryByText("Lane 1")).toBeNull(); + expect(screen.getByText(/running the build/i)).toBeTruthy(); }); - it("shows the normal preview line when the lane is not being named", () => { + it("shows the resolved lane name when naming is not active", () => { render( , ); - expect(screen.queryByText(/Auto-naming lane underway/i)).toBeNull(); + expect(screen.queryByLabelText("Naming lane…")).toBeNull(); + expect(screen.getByText("Lane 1")).toBeTruthy(); expect(screen.getByText(/running the build/i)).toBeTruthy(); }); }); @@ -920,6 +924,35 @@ describe("SessionCard status vocabulary", () => { expect(status().textContent).toContain("16s"); }); + it("keeps a chat working timer anchored to the turn when activity changes", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00.000Z")); + const renderCard = (lastActivityAt: string) => ( + + ); + const { container, rerender } = render(renderCard("2026-07-09T11:59:55.000Z")); + + const status = () => container.querySelector("[data-session-status]")!; + expect(status().textContent).toContain("14s"); + rerender(renderCard("2026-07-09T12:00:00.000Z")); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(status().textContent).toContain("16s"); + }); + it("puts role=status on the label alone so the ticker is not announced every second", () => { render( window.clearTimeout(timer); }, [canonicalPhase]); - // True while this lane's AI auto-name is being generated in the background. const isAutoNaming = useLaneNaming(lane?.id ?? null); // Brief warm highlight when the displayed title actually changes (e.g. the // deterministic/seed name is replaced by the AI name). Skipped on first mount. @@ -522,7 +522,9 @@ export const SessionCard = React.memo(function SessionCard({ > - {lane.name} + + + , ); } @@ -637,7 +639,10 @@ export const SessionCard = React.memo(function SessionCard({ icon: , value: ( - {lane?.name ?? session.laneName} + ), }); @@ -946,11 +951,7 @@ export const SessionCard = React.memo(function SessionCard({ {/* Line 3 — what it is doing, then the quiet meta. */}
- {isAutoNaming ? ( - - Auto-naming lane underway… - - ) : previewLine ? ( + {previewLine ? ( { describe("SessionListPane lane ordering, pins, chips and drag", () => { afterEach(() => { cleanup(); + setLaneNaming("lane-active", false); Reflect.deleteProperty(window, "ade"); }); @@ -1616,6 +1618,19 @@ describe("SessionListPane lane ordering, pins, chips and drag", () => { }); } + it("masks a grouped lane fallback consistently until naming finishes", () => { + setLaneNaming("lane-active", true); + const { container } = renderTwoActiveLanes(); + const header = container.querySelector('[data-section-id="lane-active"]') as HTMLElement; + + expect(within(header).getByRole("button", { name: /Naming lane…/i })).toBeTruthy(); + expect(within(header).queryByText("Active lane")).toBeNull(); + expect(header.querySelector('[title="Naming lane… · known-lane"]')).toBeTruthy(); + + act(() => setLaneNaming("lane-active", false)); + expect(within(header).getByText("Active lane")).toBeTruthy(); + }); + /** * jsdom returns an all-zero rect for every element, which would make the * midpoint 0 and every drop read as "after". Give lane headers a real box. diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index ea38ae881..56cb72b54 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -15,6 +15,7 @@ import { sessionStatusBucket, } from "../../lib/terminalAttention"; import { useAppStore } from "../../state/appStore"; +import { useLaneNaming } from "../../state/laneNamingStore"; import { useCrossMachineLaneUnion, type CrossMachineLaneMarker, @@ -25,6 +26,7 @@ import { resolveLaneAccentColor } from "../../../shared/laneColorPalette"; import { LaneMachineMarker } from "./LaneMachineMarker"; import { SessionCard } from "./SessionCard"; import { ToolLogo } from "./ToolLogos"; +import { LaneNamingLabel } from "./LaneNamingLabel"; import { LaneCombobox } from "./LaneCombobox"; import { CreateLaneDialogHost } from "../lanes/CreateLaneDialogHost"; import { @@ -395,6 +397,7 @@ function StickyGroupHeader({ sectionId, icon, label, + namingLaneId, count, collapsed, onToggleCollapsed, @@ -420,6 +423,8 @@ function StickyGroupHeader({ sectionId: string; icon: React.ReactNode; label: string; + /** Local lane whose fallback identity should be masked while auto naming. */ + namingLaneId?: string; count: number; collapsed: boolean; onToggleCollapsed: () => void; @@ -486,6 +491,7 @@ function StickyGroupHeader({ // a transformed ancestor sticks to the transformed box, so the header visibly // detaches from the top of the list mid-slide. const [sliding, setSliding] = useState(false); + const laneNaming = useLaneNaming(namingLaneId); if (count === 0) return null; const isLane = variant === "lane"; const isQuietShelf = variant === "quiet-shelf"; @@ -498,7 +504,8 @@ function StickyGroupHeader({ // flexible text nodes in one row competing for width, which is what pushed the // PR badge off the edge. Non-lane group headers keep their sub-label. const showBranchCluster = !isLane && branchText.length > 0; - const laneHeaderTitle = branchText ? `${label} · ${branchText}` : label; + const resolvedLabel = laneNaming ? "Naming lane…" : label; + const laneHeaderTitle = branchText ? `${resolvedLabel} · ${branchText}` : resolvedLabel; // `laneSurfaceTint` is now consulted for its TEXT channel only. The background, // border, and left-accent it also returns are deliberately unused here: surface // is reserved for interaction, so the lane accent moved onto the lane NAME. @@ -509,7 +516,6 @@ function StickyGroupHeader({ // the hidden item count into the label (`Settled (12)`, `Lane name (3)`), // expanded shows the bare label because the rows themselves are visible. const showInlineCount = collapsed; - const labelText = showInlineCount ? `${label} (${count})` : label; // The chevron is a second hit target for the SAME toggle, never decoration — // it looks like the control, so it has to behave like it. It cannot live // inside the label button: it sits at the far end of the trailing cluster, @@ -612,7 +618,7 @@ function StickyGroupHeader({ // two drag sources never nest. {...(busyLabel ? {} : isLane ? dragProps ?? {} : {})} {...(heading - ? { role: "heading" as const, "aria-level": 3, "aria-label": `${label} (${count})` } + ? { role: "heading" as const, "aria-level": 3, "aria-label": `${resolvedLabel} (${count})` } : {})} >
@@ -630,9 +636,9 @@ function StickyGroupHeader({ // announced. aria-expanded={!collapsed} {...(isQuietLane - ? { "aria-label": `${label} (${count} quiet)` } + ? { "aria-label": `${resolvedLabel} (${count} quiet)` } : heading - ? { "aria-label": `${label} (${count})` } + ? { "aria-label": `${resolvedLabel} (${count})` } : {})} > {icon} @@ -652,7 +658,8 @@ function StickyGroupHeader({ style={laneLabelColor ? { color: laneLabelColor } : undefined} title={laneHeaderTitle} > - {labelText} + + {showInlineCount ? ` (${count})` : null} {/* Branch sits immediately right of the label and expands to fill whatever space is free, truncating only when it runs out. */} @@ -2201,6 +2208,7 @@ export const SessionListPane = React.memo(function SessionListPane({ sectionId={lane.id} icon={laneIcon} label={lane.name} + namingLaneId={lane.id} subLabel={branchNameFromRef(lane.branchRef)} variant="lane" count={total} diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx index be16ed181..e519033bd 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx @@ -80,10 +80,9 @@ function StatusGlyph({ glyph }: { glyph: SessionStatusGlyph }) { /** * Live elapsed copy for the states where "how long" is the question the row - * raises: `Working` (how long since it last said anything) and `Stale` (same - * measurement, past the silence threshold). One reference field — the last - * output, falling back to session start — so the two readings are continuous: - * a working row's ticker simply crosses the stale threshold and keeps counting. + * raises. Active chat turns count from their immutable turn-start timestamp; + * provider activity must not reset them. CLI and stale states retain the + * last-output clock because they do not have a provider turn boundary. */ function useElapsedLabel(sinceIso: string | null | undefined, enabled: boolean): string { const sinceMs = React.useMemo(() => { @@ -169,10 +168,6 @@ export function SessionStatusSlot({ if (!actionsEnabled) setSnoozeMenuOpen(false); }, [actionsEnabled]); - const elapsed = useElapsedLabel( - session.lastActivityAt ?? session.startedAt, - Boolean(presentation?.showsElapsed), - ); const waiting = presentation?.glyph === "waiting"; const future = useFutureLabel(session.nextWakeAt, waiting); const exactWakeTitle = React.useMemo(() => { @@ -183,6 +178,10 @@ export function SessionStatusSlot({ : undefined; }, [session.nextWakeAt, waiting]); const canonicalPhase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; + const elapsedSince = canonicalPhase === "running" && isChatToolType(session.toolType) + ? session.currentTurnStartedAt ?? session.lastActivityAt ?? session.startedAt + : session.lastActivityAt ?? session.startedAt; + const elapsed = useElapsedLabel(elapsedSince, Boolean(presentation?.showsElapsed)); const isActivelyRunning = canonicalPhase === "starting" || canonicalPhase === "running" || canonicalPhase === "stale"; diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 6e5d014ae..f1c299844 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -3538,6 +3538,31 @@ button:active, [role="button"]:active { flex-shrink: 0; } +@keyframes ade-lane-naming-dot { + 0%, 20% { opacity: 0.2; } + 50%, 100% { opacity: 1; } +} + +.ade-lane-naming-dots > span { + animation: ade-lane-naming-dot 1.2s ease-in-out infinite; + animation-fill-mode: both; +} + +.ade-lane-naming-dots > span:nth-child(2) { + animation-delay: 0.16s; +} + +.ade-lane-naming-dots > span:nth-child(3) { + animation-delay: 0.32s; +} + +@media (prefers-reduced-motion: reduce) { + .ade-lane-naming-dots > span { + animation: none; + opacity: 1; + } +} + /* Sidebar lane tabs: lane name wins over branch. The branch cluster carries `flex-shrink: 999`, so it still collapses first by three orders of magnitude — the lane name only gives up space once the branch diff --git a/apps/desktop/src/renderer/lib/sessions.ts b/apps/desktop/src/renderer/lib/sessions.ts index 63622dbd4..affbe3c79 100644 --- a/apps/desktop/src/renderer/lib/sessions.ts +++ b/apps/desktop/src/renderer/lib/sessions.ts @@ -84,6 +84,7 @@ export function buildOptimisticChatSessionSummary(args: { | "laneId" | "provider" | "status" + | "currentTurnStartedAt" | "createdAt" | "lastActivityAt" | "idleSinceAt" @@ -115,6 +116,7 @@ export function buildOptimisticChatSessionSummary(args: { headShaEnd: null, lastOutputPreview: null, lastActivityAt: args.session.lastActivityAt ?? null, + currentTurnStartedAt: args.session.currentTurnStartedAt ?? null, summary: null, runtimeState: isEnded ? "exited" : args.session.status === "active" ? "running" : "idle", resumeCommand: null, diff --git a/apps/desktop/src/renderer/state/laneNamingStore.ts b/apps/desktop/src/renderer/state/laneNamingStore.ts index b8a4f3248..c660c4840 100644 --- a/apps/desktop/src/renderer/state/laneNamingStore.ts +++ b/apps/desktop/src/renderer/state/laneNamingStore.ts @@ -3,11 +3,11 @@ import { createStore } from "zustand/vanilla"; /** * Ephemeral, renderer-only signal: which lanes currently have an AI auto-naming - * pass in flight. Auto-created lanes are given a deterministic name immediately - * and the AI name is applied later via `lanes.rename`; while that background pass - * runs, session cards show an "Auto-naming…" status in place of the last-output - * line. This store is the bridge between the draft-launch flow (which owns the - * naming lifecycle) and the cards (which live in a separate component tree). + * pass in flight. Auto-created lanes are given a deterministic fallback name + * immediately and the AI identity is applied later; while that background pass + * runs, lane labels mask the fallback with an animated "Naming lane…" state. + * This store bridges the draft-launch flow (which owns the naming lifecycle) + * and lane labels elsewhere in the renderer. */ type LaneNamingState = { naming: Record; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index c3687cfa5..4b344cd9f 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -1478,6 +1478,8 @@ export type AgentChatSession = { protocolCapabilities?: string[]; runtimeMode?: AgentChatRuntimeMode; status: AgentChatSessionStatus; + /** Start of the currently active provider turn; live-only and cleared when idle. */ + currentTurnStartedAt?: string | null; idleSinceAt?: string | null; archivedAt?: string | null; threadId?: string; @@ -1530,6 +1532,8 @@ export type AgentChatSessionSummary = { codexTokenUsage?: CodexThreadTokenUsage | null; protocolCapabilities?: string[]; status: AgentChatSessionStatus; + /** Start of the currently active provider turn; null when no turn is running. */ + currentTurnStartedAt?: string | null; idleSinceAt?: string | null; startedAt: string; endedAt: string | null; diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 4c9d005f9..d6619363e 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -490,13 +490,15 @@ export type LaneDeleteEvent = { * renderer can surface a toast without polling. Distinct from * {@link LaneDeleteEvent}, which streams per-step delete progress; this fires a * single time on successful completion. `lane` carries the full summary for - * created lanes (the create paths already have it); archive/delete/rename only - * need enough metadata for notices and renderer list invalidation. + * created lanes (the create paths already have it); archive/delete/rename and + * identity completion only need enough metadata for notices and renderer list + * invalidation. */ export type LaneLifecycleEvent = { type: | "lane-created" | "lane-renamed" + | "lane-updated" | "lane-archived" | "lane-unarchived" | "lane-reclaimed" diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index eab4afc52..5991106a0 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -188,6 +188,8 @@ export type TerminalSessionSummary = { * Used to detect sessions that are old *and untouched*, vs. old-but-active. */ lastActivityAt?: string | null; + /** Start of the current chat turn. Unlike lastActivityAt, output does not move it. */ + currentTurnStartedAt?: string | null; summary: string | null; runtimeState: TerminalRuntimeState; pendingInputItemId?: string | null; diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 0fbe9ab9d..eab2522b7 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -577,10 +577,15 @@ Controls and summaries project this runtime state rather than owning it: auto-created lanes start at `creating-lane` because they are named deterministically up front (`createDeterministicAutoLaneName`) and created without waiting on the model. When AI titles are enabled the - real name is generated in the background after creation and applied via - `lanes.rename` (`startBackgroundLaneNaming`), surfaced through the - per-lane `laneNamingStore` as an "Auto-naming…" status on session - cards rather than a blocking launch-job phase. Jobs live in the **root** + structured title + branch identity is generated in the background after + creation. The deterministic fallback remains persisted but is masked by + `Naming lane…` in lane-label positions until the completed identity is + refreshed; failures reveal the fallback. This is surfaced through the + per-lane `laneNamingStore` rather than a blocking launch-job phase. + Active turns also carry `currentTurnStartedAt`: local optimistic starts and + authoritative `status: started` events establish it once, while streamed + activity and steers update `lastActivityAt` without resetting the turn clock. + Jobs live in the **root** `appStore.draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`, not the per-project store), scoped by project root, lane, surface profile, and Work draft kind. The root @@ -865,7 +870,8 @@ session primitives: requested, configured, and fallback title models) and renames every child to `-` in place; one child's rename failure does not abort the rest, and the children are flagged in - `laneNamingStore` while the pass runs. If no model produces a usable + `laneNamingStore` so their lane labels are masked while the pass runs. + If no model produces a usable name the deterministic base is kept; the generic empty-prompt fallback is `parallel-task`. 3. For each child lane it creates an `AgentChatSession`, sends the same diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 21b9b977e..bcb3650f5 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -542,18 +542,19 @@ that could not work without it. deterministically from the prompt (`createDeterministicAutoLaneName`) and created **immediately** — naming never sits on the critical path, so there is no 10 s suggest race anymore. When AI titles are enabled, - `startBackgroundLaneNaming` then asks the main process for a name in the - background (the backend has its own timeout and returns the - deterministic fallback on failure, so a no-op result is skipped) and, - if the suggestion differs, applies it via `lanes.rename` and refreshes - the lane store. The renderer retries the background naming pass once - (750 ms apart) before keeping the deterministic slug. Branch uniqueness - is handled by the lane id suffix added by the lane service. Each launch creates a `DraftLaunchJob` that + `startBackgroundLaneNaming` asks the main process for a structured lane + title + branch identity in the background. The deterministic fallback stays + persisted for failure safety but is masked in lane-label positions by an + animated `Naming lane…` state. The renderer retries the background naming + pass once (750 ms apart), refreshes the completed identity before unmasking + it, and reveals the fallback only when naming fails or produces no change. + Branch uniqueness is resolved by the lane service. Each launch creates a `DraftLaunchJob` that tracks progress through `creating-lane` / `starting-session` / `sending-prompt` / `ready` / `failed` states (auto-create no longer has a distinct `naming-lane` phase — it goes straight to `creating-lane`). While the background pass runs, affected lanes are flagged in - `laneNamingStore` so session cards show "Auto-naming lane underway…". + `laneNamingStore` so singleton cards, hover details, and grouped lane headers + all show `Naming lane…`. The composer is cleared optimistically when the job starts so the user can begin composing the next prompt immediately; the `DraftLaunchSnapshot` diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index eb2b46566..dfaee16d2 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -544,7 +544,7 @@ fork points. | `laneService.listBranchProfiles(laneId)` | Returns every branch profile recorded for the lane plus the active branch (auto-upserts a profile for the lane's current `branch_ref` so the active branch is always present). | | `laneService.previewBranchSwitch(args)` | Pure read: dirty-tree probe, duplicate-owner detection (another lane already on that branch), active terminal/process inventory, base-ref/parent inference, remote-prefix stripping. Used to drive the iOS/desktop branch picker confirmation UI. | | `laneService.switchBranch(args)` | Performs the checkout: refuses dirty trees, refuses duplicate-owner branches, requires `acknowledgeActiveWork` if active sessions/processes exist, then `git checkout` (or `checkout -b` in `mode: "create"`), updates the lane row, upserts the branch profile, and prunes stale `pull_requests` rows whose `head_branch` no longer matches the new branch. (`pull_requests.lane_id` is `not null`, so stale rows are deleted along with their child rows in `pr_group_members`.) | -| `laneService.updateBranchRef(laneId, branchRef)` | Internal helper used after rename/import paths to keep the active profile and `lanes.branch_ref` in sync. | +| `laneService.updateBranchRef(laneId, branchRef)` | Internal helper used after rename/import paths to keep the active profile and `lanes.branch_ref` in sync. After the transaction commits it emits the refresh-only `lane-updated` lifecycle event so Work hover details, Lanes, and both Git Actions panes replace stale branch identity immediately. | IPC channels (registered in `services/ipc/registerIpc.ts`, exposed via `preload.ts`): @@ -680,7 +680,7 @@ Lane management (selected): | `ade.lanes.delete.risk` | `(args: { laneId }) => LaneDeleteRisk` — preflight read for the manage dialog: dirty state, unpushed commit count, remote-branch existence, active PTYs/watchers, env-init flag. | | `ade.lanes.delete.cancel` | `(args: { laneId }) => { cancelled, reason? }` — cooperative cancel during the early teardown steps. After `git_worktree_remove` starts the lane is unrecoverable and cancel is a no-op. | | `ade.lanes.delete.event` (push) | `LaneDeleteEvent` carrying `LaneDeleteProgress` — `steps[]` with per-step status (`pending` / `running` / `completed` / `failed` / `skipped`) plus `overallStatus` (`running` / `completed` / `failed` / `cancelled`) and `cancellable`. | -| `ade.lanes.lifecycle.event` (push) | `LaneLifecycleEvent` - one-shot `lane-created`, `lane-renamed`, `lane-archived`, `lane-reclaimed`, `lane-unarchived`, `lane-restored`, or `lane-deleted` event. Local desktop paths emit this IPC channel directly; runtime-backed paths push `lane_lifecycle_event`, and preload merges both sources behind `window.ade.lanes.onLifecycleEvent`. | +| `ade.lanes.lifecycle.event` (push) | `LaneLifecycleEvent` - one-shot `lane-created`, `lane-renamed`, refresh-only `lane-updated`, `lane-archived`, `lane-reclaimed`, `lane-unarchived`, `lane-restored`, or `lane-deleted` event. Auto identity emits `lane-updated` only after the renamed branch is persisted; `useLaneListInvalidation` refreshes every lane consumer and `useLaneEventToasts` intentionally ignores this internal event. Local desktop paths emit this IPC channel directly; runtime-backed paths push `lane_lifecycle_event`, and preload merges both sources behind `window.ade.lanes.onLifecycleEvent`. | | `ade.lanes.delete.progress.list` | replay of the in-memory `LaneDeleteProgress` map for currently running deletes. Completed delete results are delivered through the live event stream; a remount after completion refreshes the lane list instead of replaying historical progress. | | `ade.lanes.getBranchDrift` | `(args: { laneId: string }) => LaneBranchDrift \| null` — fresh HEAD read for callers about to act on the branch; `null` for archived lanes, an unavailable worktree, a detached HEAD, or no drift. See [Branch drift](#branch-drift). | | `ade.lanes.resolveBranchDrift` | `(args: ResolveLaneBranchDriftArgs) => ResolveLaneBranchDriftResult` — `switch-back` checks the worktree back onto the recorded branch; `keep-head` adopts the live HEAD (and renames a branch-advertising lane name) in one transaction. | diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index fbf98cfd6..8bc8c1a5b 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -146,8 +146,9 @@ and in tests. canonical bridge from `AgentChatSessionSummary` runtime truth to the `terminal_sessions` row used by Work, detail reads, ADE runtime actions, and lane snapshots. - It projects active/idle/waiting state, pending input, wake time, and - orchestration lineage. If chat hydration fails, a persisted resumable + It projects active/idle/waiting state, pending input, the live + `currentTurnStartedAt` timer anchor, wake time, and orchestration lineage. + If chat hydration fails, a persisted resumable `status = "running"` row falls back to quiet idle/waiting instead of presenting a false live/green agent. - `apps/desktop/src/main/services/sessions/settleTerminalSession.ts` — @@ -191,7 +192,8 @@ Shared types and IPC: - `apps/desktop/src/shared/types/sessions.ts` — `TerminalSessionSummary`, `TerminalSessionStatus`, `TerminalToolType`, `TerminalRuntimeState`, `TerminalResumeMetadata` (including tracked CLI spawn lineage), - `PtyCreateArgs`, `SessionDeltaSummary`, + `PtyCreateArgs`, `SessionDeltaSummary`, and the optional + `currentTurnStartedAt` anchor used by active chat Working timers, offset-stamped `PtyDataEvent`, `PtySendToSessionArgs` / `PtySendToSessionResult` (the send-or-continue surface), `PtyResumeSessionArgs` / @@ -297,7 +299,9 @@ Shared types and IPC: - `apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx` — the row's single status surface and no-layout-shift hover/focus action swap. It maps shared presentation glyph ids to Phosphor icons, ticks active - Working/Planning elapsed time and idle scheduled-work countdowns, and + chat Working/Planning elapsed time from immutable `currentTurnStartedAt`, + falling back to last activity for legacy rows, keeps CLI/Stale elapsed time + on last activity, ticks idle scheduled-work countdowns, and replaces the status label with snooze plus binding-aware settle/un-settle controls while the row is hovered or keyboard-focused. - `apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx` — @@ -679,7 +683,9 @@ Renderer surfaces: identity and PR navigation onto the card. After one second `SessionHoverCard` carries the lower-frequency metadata removed from the row (including clickable PR and parent-thread facts). The - title still warm-highlights when background AI naming lands, and + Lane labels on singleton rows and hover details show the shared animated + `Naming lane…` placeholder while background identity generation is active. + The title still warm-highlights when background AI naming lands, and `disabledReason` blocks selection, dragging, and the context menu during lane deletion. Selection/hover use the row background; non-prominent lifecycle states recede instead of spending lane-tinted card surfaces. @@ -687,9 +693,13 @@ Renderer surfaces: renderer-only zustand store tracking which lanes have an AI auto-naming pass in flight. `setLaneNaming(laneId, on)` is the imperative setter the draft-launch / parallel-launch flow toggles; - `useLaneNaming(laneId)` is the card-side subscription. Bridges the - draft-launch flow (which owns the naming lifecycle) to session cards in - a separate component tree. + `useLaneNaming(laneId)` is the label-side subscription. Bridges the + draft-launch flow (which owns the naming lifecycle) to singleton cards, + hover details, and grouped lane headers in a separate component tree. +- `apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx` — + shared reduced-motion-aware `Naming lane…` label with three animated dots; + callers supply the resolved naming state so visible and accessible labels + stay consistent. - `apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx` — tabs/grid/single Work view. The grid mode renders through the shared `PaneTilingLayout`; the seed tree comes from diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index c005cd28f..9d3ef1c0b 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -248,22 +248,22 @@ The full card is one full-bleed row with three lines: 1. **Where + status** — an adaptive identity slot on the left and `SessionStatusSlot` on the right. The identity slot can carry the owning machine, a pin, the lane identity for a singleton lane, spawned-chat - lineage, and a branch only when it differs from the lane's declared branch. - When none of those apply, session delta or last-activity time is the floor, - so the line never renders empty. A grouped lane owns machine identity and PR - state in its header; child rows do not repeat them. Lane identity always - uses the lane accent and `LaneIcon`; branch identity is always muted and - uses `BranchIcon`. + lineage, a branch only when it differs from the lane's declared branch, and + the lane PR for a singleton. When none of those apply, session delta or + last-activity time is the floor, so the line never renders empty. A grouped + lane owns machine identity and PR state in its header; child rows do not + repeat them. Lane identity always uses the lane accent and `LaneIcon`; + branch identity is always muted and uses `BranchIcon`. 2. **Title + singleton PR** — `primarySessionLabel()` is the prominent, elastic element. When the card stands in for a one-session lane, the shared `LanePrBadge` sits at the right edge directly beneath the lifecycle status, - stays untruncated, and deep-links to the PR in ADE. When the card's lane is mid - background AI auto-naming (`useLaneNaming(lane.id)` from - `renderer/state/laneNamingStore.ts` is true), a title change gets a short - lane-accent highlight. -3. **Preview + quiet metadata** — while auto-naming it shows - "Auto-naming lane underway…"; otherwise it shows - an explicit `attentionMessage` first, then `statusNote` (`done: …` in the + stays untruncated, and deep-links to the PR in ADE. While the owning lane is + mid background AI naming, every visible lane-label position (singleton row, + hover detail, or grouped header) uses the shared animated `Naming lane…` + placeholder; the persisted deterministic fallback stays hidden unless + naming fails. A resolved title change gets a short lane-accent highlight. +3. **Preview + quiet metadata** — preview content remains visible during lane + naming. It shows an explicit `attentionMessage` first, then `statusNote` (`done: …` in the settled tier), then a sanitized `lastOutputPreview`, then `session.summary`, then `session.goal`. Output fallback is plain text (never linkified), capped at 120 characters, and strips ANSI/control sequences plus @@ -280,7 +280,9 @@ tasks keep the row at **Working**, while an armed `nextWakeAt` reads neutral **Waiting** with a compact countdown. These contextual labels do not change the canonical lifecycle, filing bucket, filters, or attention count, and CLI output is never scraped to infer plan mode. Working/Planning elapsed time ticks from -last activity; Waiting refreshes on a quiet 30-second cadence. On row hover or +the active chat's immutable `currentTurnStartedAt`, so streamed activity cannot +reset it; legacy chat rows without that anchor, plus CLI and Stale durations, +use last activity. Waiting refreshes on a quiet 30-second cadence. On row hover or keyboard focus the status swaps, without reflow, for `SessionSnoozeControl` and the context-appropriate Settle or Un-settle action. An open snooze menu pins the action slot visible. A row whose snooze ended early shows the shared Woke From 31007cbe3a2e49cf00e023086437f80144ae0b59 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:18:23 -0400 Subject: [PATCH 2/9] fix(github): preserve local auth precedence --- CHANGELOG.md | 1 + apps/ade-cli/src/cli.ts | 2 + .../src/headlessLinearServices.test.ts | 90 +++++++++++++++++-- apps/ade-cli/src/headlessLinearServices.ts | 31 ++++--- .../tuiClient/__tests__/appPolling.test.tsx | 47 +++++++++- apps/ade-cli/src/tuiClient/app.tsx | 7 ++ .../services/github/githubService.test.ts | 76 +++++++++++++++- .../src/main/services/github/githubService.ts | 17 ++-- .../main/services/lanes/laneService.test.ts | 2 +- .../src/main/services/lanes/laneService.ts | 4 +- .../app/toast/useLaneEventToasts.ts | 2 +- .../components/chat/AgentChatPane.tsx | 26 ++---- .../components/settings/GitHubSection.tsx | 47 +++++----- .../components/terminals/SessionCard.test.tsx | 26 ++++++ .../lib/githubIntegrationStatus.test.ts | 60 +++++++++++++ .../renderer/lib/githubIntegrationStatus.ts | 59 ++++++++++++ apps/desktop/src/shared/types/lanes.ts | 6 +- docs/features/lanes/README.md | 4 +- .../onboarding-and-settings/README.md | 27 +++--- 19 files changed, 450 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7412f8b..226a5e6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Work and reliability - Show an animated `Naming lane…` placeholder while automatic lane identity is still resolving, reveal the deterministic fallback only on failure, refresh renamed branches live across Lanes, Git Actions, and session hover details, and keep sidebar Working durations anchored to each chat turn. +- Keep write-capable environment, personal access token, and GitHub CLI credentials ahead of read-only GitHub App authorization, and show App installation permissions without false classic OAuth scope errors. ## [1.2.46] - 2026-07-30 diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 3baae0f74..485f95388 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1216,6 +1216,8 @@ const HELP_BY_COMMAND: Record = { - login keeps one connection open for the whole device flow because the device-auth session lives in runtime memory; do not split start and poll across separate invocations in headless mode. + - GitHub operations prefer environment tokens, stored PATs, and GitHub CLI + auth in that order; GitHub App user authorization is the fallback. Flags (login): --max-wait Give up waiting after N seconds (default: GitHub's diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 33134ae2e..59eeda07e 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -669,12 +669,12 @@ describe("headlessLinearServices", () => { } }); - it("uses GitHub App authorization before a stored machine PAT for async REST calls", async () => { + it("keeps a stored machine PAT ahead of GitHub App authorization for async REST calls", async () => { const previousAdeHome = process.env.ADE_HOME; const previousFetch = globalThis.fetch; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-")); const machineCredentialStore = new EncryptedFileCredentialStore(); - machineCredentialStore.setSync("github.token.v1", "ghp_stale_stored_token"); + machineCredentialStore.setSync("github.token.v1", "ghp_stored_token"); machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_app_user_token", tokenType: "bearer", @@ -687,12 +687,15 @@ describe("headlessLinearServices", () => { })); const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const authorization = new Headers(init?.headers).get("authorization"); - if (authorization !== "Bearer ghu_app_user_token") { + if (authorization !== "Bearer ghp_stored_token") { return new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 }); } return new Response(JSON.stringify({ login: "octocat" }), { status: 200, - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-oauth-scopes": "repo, workflow", + }, }); }) as unknown as typeof fetch; globalThis.fetch = fetchImpl; @@ -703,14 +706,91 @@ describe("headlessLinearServices", () => { ); try { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ - authSource: "app", + authSource: "pat", connected: true, + patTokenStored: true, userLogin: "octocat", }); + expect(fetchImpl).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer ghp_stored_token", + }), + }), + ); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + } + }); + + it("keeps GitHub CLI auth ahead of GitHub App authorization for async REST calls", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousGhConfigDir = process.env.GH_CONFIG_DIR; + const previousDisableGhAuthFallback = process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-")); + process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-gh-config-")); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + fs.writeFileSync( + path.join(process.env.GH_CONFIG_DIR, "hosts.yml"), + "github.com:\n oauth_token: gho_cli_token\n", + ); + const machineCredentialStore = new EncryptedFileCredentialStore(); + machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization"); + if (authorization !== "Bearer gho_cli_token") { + return new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 }); + } + return new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-oauth-scopes": "repo, workflow", + }, + }); + }) as unknown as typeof fetch; + globalThis.fetch = fetchImpl; + + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + ); + try { + await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ + authSource: "gh", + connected: true, + patTokenStored: false, + userLogin: "octocat", + }); + expect(fetchImpl).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer gho_cli_token", + }), + }), + ); } finally { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; else process.env.ADE_HOME = previousAdeHome; + if (previousGhConfigDir == null) delete process.env.GH_CONFIG_DIR; + else process.env.GH_CONFIG_DIR = previousGhConfigDir; + if (previousDisableGhAuthFallback == null) delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + else process.env.ADE_DISABLE_GH_AUTH_FALLBACK = previousDisableGhAuthFallback; } }); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 21a543c76..4732d8bf8 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -622,6 +622,24 @@ export function createHeadlessGitHubService( ghAuthError: null, }; } + const patToken = await readStoredPatTokenAsync(); + if (patToken) { + return { + token: patToken, + source: "pat", + patTokenStored: true, + ghCliPath: null, + ghAuthError: null, + }; + } + const gh = await ghAuthTokenAsync(); + if (gh.token) { + return { + ...gh, + source: "gh", + patTokenStored: false, + }; + } const appToken = appUserAuth.getAuthStatus().tokenStored ? await appUserAuth.getValidTokenForRelay().catch(() => null) : null; @@ -634,20 +652,9 @@ export function createHeadlessGitHubService( ghAuthError: null, }; } - const patToken = await readStoredPatTokenAsync(); - if (patToken) { - return { - token: patToken, - source: "pat", - patTokenStored: true, - ghCliPath: null, - ghAuthError: null, - }; - } - const gh = await ghAuthTokenAsync(); return { ...gh, - source: gh.token ? "gh" : "none", + source: "none", patTokenStored: false, }; }; diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index 4086a0d26..5fc5055c0 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -4,6 +4,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "ink-testing-library"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import type { BufferedEvent } from "../../eventBuffer"; import type { AdeCodeConnection, ProjectLaunchContext } from "../types"; import { captureTuiProductAnalytics, deriveTuiAnalyticsScreen } from "../productAnalytics"; @@ -204,6 +205,10 @@ async function unmountApp(instance: ReturnType) { describe("AdeCodeApp polling", () => { let chatListeners: Set<(event: AgentChatEventEnvelope) => void>; + let runtimeListeners: Array<{ + category: BufferedEvent["category"] | null | undefined; + callback: (event: BufferedEvent) => void; + }>; let connection: AdeCodeConnection; const project: ProjectLaunchContext = { launchCwd: "/repo", @@ -218,6 +223,7 @@ describe("AdeCodeApp polling", () => { beforeEach(() => { vi.useFakeTimers(); chatListeners = new Set(); + runtimeListeners = []; connection = { mode: "attached", projectRoot: "/repo", @@ -233,7 +239,13 @@ describe("AdeCodeApp polling", () => { chatListeners.delete(callback); }; }), - subscribeRuntimeEvents: vi.fn(async () => () => {}), + subscribeRuntimeEvents: vi.fn(async (args, callback) => { + const listener = { category: args.category, callback }; + runtimeListeners.push(listener); + return () => { + runtimeListeners = runtimeListeners.filter((candidate) => candidate !== listener); + }; + }), close: vi.fn(async () => {}), }; mocks.connectToAde.mockResolvedValue(connection); @@ -294,6 +306,39 @@ describe("AdeCodeApp polling", () => { await unmountApp(instance); }); + it("refreshes lane identity immediately when the branch update lifecycle event arrives", async () => { + const instance = await renderApp(); + const initialLaneCalls = mocks.listLanes.mock.calls.length; + const runtimeListener = runtimeListeners.find((listener) => listener.category === "runtime"); + expect(runtimeListener).toBeTruthy(); + + mocks.listLanes.mockResolvedValue([ + lane({ name: "Automatic lane naming", branchRef: "ade/automatic-lane-naming" }), + ]); + await act(async () => { + runtimeListener!.callback({ + id: 1, + timestamp: "2026-01-01T00:00:01.000Z", + category: "runtime", + payload: { + type: "lane_lifecycle_event", + event: { + type: "lane-branch-updated", + laneId: "lane-1", + laneName: "Automatic lane naming", + }, + }, + }); + }); + await flushAsyncEffects(); + + expect(mocks.listLanes.mock.calls.length).toBeGreaterThan(initialLaneCalls); + expect(mocks.listLanes).toHaveBeenLastCalledWith(connection, { includeStatus: true }); + expect(stripAnsi(instance.frames.join("\n"))).toContain("ade/automatic-lane-naming"); + + await unmountApp(instance); + }); + it("does not emit analytics for polling or background event streams", async () => { const instance = await renderApp(); const analyticsCalls = () => vi.mocked(connection.action).mock.calls.filter( diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 7636e70f3..5698bd86d 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -8489,6 +8489,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, onGap: handleRuntimeEventGap, }, (event) => { const type = typeof event.payload.type === "string" ? event.payload.type : ""; + if (type === "lane_lifecycle_event") { + const laneEvent = event.payload.event as { type?: unknown } | undefined; + if (laneEvent?.type === "lane-branch-updated") { + void refreshState({ hydrateHistory: false }).catch(() => undefined); + } + return; + } if (type !== "prs-updated" && type !== "pr-notification") return; void refreshPrsByLane(); void refreshState({ hydrateHistory: false }).catch(() => undefined); diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 731d59acf..f41e37de8 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -764,10 +764,82 @@ describe("githubService.getStatus", () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - it("uses GitHub App authorization before a stored PAT for async REST calls", async () => { + it("keeps a stored PAT ahead of GitHub App authorization for async REST calls", async () => { + stubOriginRemote(); + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_stored_token"); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), + ); + + const status = await makeService({ credentialStore }).getStatus(); + + expect(status).toMatchObject({ + authSource: "pat", + connected: true, + patTokenStored: true, + repoAccessOk: null, + userLogin: "alice", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).authorization) + .toBe("Bearer ghp_stored_token"); + }); + + it("keeps GitHub CLI auth ahead of GitHub App authorization for async REST calls", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), + ); + + const status = await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).getStatus(); + + expect(status).toMatchObject({ + authSource: "gh", + connected: true, + patTokenStored: false, + repoAccessOk: null, + userLogin: "alice", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).authorization) + .toBe("Bearer gho_cli_token"); + }); + + it("falls back to GitHub App authorization when no local credential is available", async () => { stubOriginRemote(); const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.token.v1", "ghp_stale_stored_token"); credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_app_user_token", tokenType: "bearer", diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 65e8a1bbc..1a0a36f17 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -712,8 +712,16 @@ export function createGithubService({ readEnvironmentAuthToken() ?? readPatAuthToken(); const readAuthToken = async (): Promise => { - const environment = readEnvironmentAuthToken(); - if (environment) return environment; + const primary = readPrimaryAuthToken(); + if (primary) return primary; + const gh = await readGhAuthToken(); + if (gh.token) { + return { + ...gh, + source: "gh", + patTokenStored: false, + }; + } const appToken = appUserAuth.getAuthStatus().tokenStored ? await appUserAuth.getValidTokenForRelay().catch(() => null) : null; @@ -726,12 +734,9 @@ export function createGithubService({ ghAuthError: null, }; } - const pat = readPatAuthToken(); - if (pat) return pat; - const gh = await readGhAuthToken(); return { ...gh, - source: gh.token ? "gh" : "none", + source: "none", patTokenStored: false, }; }; diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 7fff4f96e..73514e6f5 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -815,7 +815,7 @@ describe("laneService automatic lane identity", () => { defaultBaseRef: "main", worktreesDir: path.join(repoRoot, "worktrees"), onLifecycleEvent: (event) => { - if (event.type !== "lane-updated") return; + if (event.type !== "lane-branch-updated") return; const row = db.get<{ branch_ref: string }>( "select branch_ref from lanes where id = ?", ["lane-child"], diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b5a73375a..38994d2fb 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -5604,7 +5604,7 @@ export function createLaneService({ const reconciledLane = getLaneRow(args.laneId); if (reconciledLane) { broadcastLifecycleEvent({ - type: "lane-updated", + type: "lane-branch-updated", laneId: args.laneId, laneName: reconciledLane.name, color: reconciledLane.color, @@ -6666,7 +6666,7 @@ export function createLaneService({ const updated = getLaneRow(laneId); if (updated) { broadcastLifecycleEvent({ - type: "lane-updated", + type: "lane-branch-updated", laneId, laneName: updated.name, color: updated.color, diff --git a/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts b/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts index cb6fd34e3..a27baec78 100644 --- a/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts +++ b/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts @@ -28,7 +28,7 @@ export function useLaneEventToasts(navigate: NavigateFunction): void { }); return; } - if (event.type === "lane-renamed" || event.type === "lane-updated") return; + if (event.type === "lane-renamed" || event.type === "lane-branch-updated") return; showToast({ id: `lane-${event.type}-${event.laneId}`, title: event.laneName, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index d51fe16ad..af7368942 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -8678,6 +8678,7 @@ export function AgentChatPane({ // fallback on failure, so a no-op result is skipped), apply it via `apply`, then // always clear the flags. Naming never sits on the critical path — lanes are // created instantly with deterministic names and upgraded here in the background. + // When applySuggestion is omitted, the backend owns the identity mutation. const runBackgroundLaneNaming = useCallback((args: { laneId: string; prompt: string; @@ -8687,8 +8688,7 @@ export function AgentChatPane({ attachments?: AgentChatFileRef[]; flagLaneIds: string[]; pin?: OpenProjectBinding | null; - backendAppliesLaneTitle?: boolean; - apply: (suggested: string) => Promise; + applySuggestion?: (suggested: string) => Promise; }) => { if (!args.flagLaneIds.length) return; for (const id of args.flagLaneIds) setLaneNaming(id, true); @@ -8729,17 +8729,13 @@ export function AgentChatPane({ break; } const suggested = suggestion?.laneTitle.trim() ?? ""; - if (suggested && suggested !== args.fallbackName && !args.backendAppliesLaneTitle) { - await args.apply(suggested); - if (canRefreshPinnedProject(args.pin)) { - await refreshLanesStore().catch(() => undefined); - } + const hasNewSuggestion = Boolean(suggested && suggested !== args.fallbackName); + if (hasNewSuggestion && args.applySuggestion) { + await args.applySuggestion(suggested); } - // The backend applies both the lane title and branch for single-lane - // auto naming. Refresh before unmasking the fallback so every renderer - // consumer (card hover, Lanes, and both Git Actions panes) sees the - // completed identity atomically. - if (args.backendAppliesLaneTitle && canRefreshPinnedProject(args.pin)) { + // Refresh after a renderer-applied suggestion, or unconditionally when + // the backend owns both the lane title and branch identity mutation. + if ((!args.applySuggestion || hasNewSuggestion) && canRefreshPinnedProject(args.pin)) { await refreshLanesStore().catch(() => undefined); } } catch (error) { @@ -8763,10 +8759,6 @@ export function AgentChatPane({ runBackgroundLaneNaming({ ...args, flagLaneIds: [args.laneId], - backendAppliesLaneTitle: true, - apply: (suggested) => args.pin - ? window.ade.lanes.rename({ laneId: args.laneId, name: suggested }, args.pin) - : window.ade.lanes.rename({ laneId: args.laneId, name: suggested }), }); }, [runBackgroundLaneNaming]); @@ -8789,7 +8781,7 @@ export function AgentChatPane({ fallbackName: args.fallbackBase, flagLaneIds: args.children.map((child) => child.laneId), pin: args.pin, - apply: async (suggested) => { + applySuggestion: async (suggested) => { for (const child of args.children) { const renameArgs = { laneId: child.laneId, name: `${suggested}-${child.suffix}` }; const rename = args.pin diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index 380e24b50..03fd160f7 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -18,7 +18,10 @@ import { import { getGitHubTokenAccessState, REQUIRED_GITHUB_CLASSIC_SCOPES } from "../../../shared/githubScopes"; import { COLORS, MONO_FONT, SANS_FONT, cardStyle, LABEL_STYLE, inlineBadge, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; import { GitHubAppInstallPanel } from "../github/GitHubAppInstallPanel"; -import { describeGithubAuthFailure } from "../../lib/githubIntegrationStatus"; +import { + describeGithubAuthFailure, + githubCredentialPresentation, +} from "../../lib/githubIntegrationStatus"; type TokenType = "classic" | "fine-grained" | "unknown"; @@ -72,21 +75,6 @@ function authSourceLabel(status: GitHubStatus | null): string { } } -function tokenTypeLabel(status: GitHubStatus | null): string { - switch (status?.tokenType) { - case "classic": - return "Classic PAT"; - case "fine-grained": - return "Fine-grained PAT"; - case "oauth": - return "OAuth token"; - case "unknown": - return "Unknown token"; - default: - return "N/A"; - } -} - export function GitHubSection({ embedded = false }: { embedded?: boolean }) { const [actionError, setActionError] = useState(null); const [saveNotice, setSaveNotice] = useState(null); @@ -182,13 +170,18 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { const tokenAuthenticated = Boolean(githubStatus?.tokenStored && githubStatus?.userLogin); const isConnected = Boolean(githubStatus?.connected); + const credentialPresentation = githubCredentialPresentation(githubStatus); + const permissionMode = credentialPresentation.permissionMode; const isFineGrainedToken = githubStatus?.tokenType === "fine-grained"; const authFailure = githubStatus?.authFailure ?? null; const authFailurePresentation = githubStatus ? describeGithubAuthFailure(githubStatus) : null; - const hasInspectableScopes = !isFineGrainedToken || (githubStatus?.scopes?.length ?? 0) > 0; + const hasInspectableScopes = credentialPresentation.hasInspectableScopes; const accessState = getGitHubTokenAccessState(githubStatus?.scopes ?? []); const repoProbeFailed = tokenAuthenticated && githubStatus?.repoAccessOk === false; - const hasMissingScopes = !authFailure && tokenAuthenticated && hasInspectableScopes && !accessState.hasRequiredAccess; + const hasMissingScopes = permissionMode === "scopes" + && tokenAuthenticated + && hasInspectableScopes + && !accessState.hasRequiredAccess; let statusColor: string; let statusLabel: string; if (isConnected) { @@ -366,15 +359,15 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { {summaryCell("USER", githubStatus?.userLogin ?? null)} {summaryCell("REPOSITORY", githubStatus?.repo ? `${githubStatus.repo.owner}/${githubStatus.repo.name}` : null)} {summaryCell("AUTH METHOD", authSourceLabel(githubStatus))} - {summaryCell("TOKEN TYPE", tokenTypeLabel(githubStatus))} + {summaryCell("TOKEN TYPE", credentialPresentation.tokenTypeLabel)} {githubStatus?.rateLimit ? summaryCell("API QUOTA", rateLimitLabel) : null}
- {authFailure ? "AUTHENTICATION CHECK" : isFineGrainedToken && !hasInspectableScopes ? "TOKEN PERMISSIONS" : "DETECTED SCOPES"} + {credentialPresentation.permissionHeading}
- {authFailure ? ( + {permissionMode === "auth-failure" ? (
{authFailurePresentation?.settingsDetail}
- ) : isFineGrainedToken && !hasInspectableScopes ? ( + ) : permissionMode === "app" ? ( +
+
+ + {credentialPresentation.repoAccessLabel} +
+
+ GitHub App user tokens do not use classic OAuth scopes. Their capabilities come from the ADE for GitHub installation, and the repository check above verifies metadata access only—not write permissions. ADE prefers environment, personal access token, and GitHub CLI credentials for GitHub operations, and uses this App authorization only as a fallback. +
+
+ ) : permissionMode === "fine-grained" ? (
{REQUIRED_GITHUB_FINE_GRAINED_PERMISSIONS.map((permission) => (
diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 725948c80..b7f50743e 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -953,6 +953,32 @@ describe("SessionCard status vocabulary", () => { expect(status().textContent).toContain("16s"); }); + it("uses the last activity boundary for active chats from older hosts", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00.000Z")); + const { container } = render( + , + ); + + const status = container.querySelector("[data-session-status]")!; + expect(status.getAttribute("data-session-status")).toBe("Working"); + expect(status.textContent).toContain("14s"); + expect(status.textContent).not.toContain("8d"); + }); + it("puts role=status on the label alone so the ticker is not announced every second", () => { render( { expect(copy?.settingsDetail).toBe("GitHub returned an unexpected enterprise policy response."); }); }); + +describe("githubCredentialPresentation", () => { + it("treats GitHub App authorization as installation permissions, not OAuth scopes", () => { + const presentation = githubCredentialPresentation(makeCliStatus({ + authSource: "app", + tokenType: "oauth", + userLogin: "arul28", + repoAccessOk: true, + scopes: [], + connected: true, + })); + + expect(presentation).toEqual({ + tokenTypeLabel: "GitHub App user token", + permissionMode: "app", + permissionHeading: "APP PERMISSIONS", + hasInspectableScopes: false, + repoAccessLabel: "Repository metadata access verified", + }); + }); + + it("keeps classic and fine-grained token permission displays distinct", () => { + expect(githubCredentialPresentation(makeCliStatus({ + tokenType: "classic", + scopes: ["repo", "workflow"], + }))).toEqual({ + tokenTypeLabel: "Classic PAT", + permissionMode: "scopes", + permissionHeading: "DETECTED SCOPES", + hasInspectableScopes: true, + repoAccessLabel: "Repository access not checked", + }); + expect(githubCredentialPresentation(makeCliStatus({ + tokenType: "fine-grained", + scopes: [], + }))).toEqual({ + tokenTypeLabel: "Fine-grained PAT", + permissionMode: "fine-grained", + permissionHeading: "TOKEN PERMISSIONS", + hasInspectableScopes: false, + repoAccessLabel: "Repository access not checked", + }); + }); + + it("makes authentication failure the single highest-priority permission mode", () => { + expect(githubCredentialPresentation(makeCliStatus({ + authSource: "app", + authFailure: { + kind: "network", + message: "offline", + retryAt: null, + }, + }))).toMatchObject({ + permissionMode: "auth-failure", + permissionHeading: "AUTHENTICATION CHECK", + hasInspectableScopes: false, + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts index 8cd21ded2..da7583e2b 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts @@ -4,6 +4,65 @@ import type { GitHubStatus, } from "../../shared/types"; +export type GithubCredentialPresentation = { + tokenTypeLabel: string; + permissionMode: "auth-failure" | "app" | "fine-grained" | "scopes"; + permissionHeading: string; + hasInspectableScopes: boolean; + repoAccessLabel: string; +}; + +export function githubCredentialPresentation( + status: GitHubStatus | null, +): GithubCredentialPresentation { + if (status?.authFailure) { + return { + tokenTypeLabel: githubTokenTypeLabel(status), + permissionMode: "auth-failure", + permissionHeading: "AUTHENTICATION CHECK", + hasInspectableScopes: false, + repoAccessLabel: githubRepoAccessLabel(status.repoAccessOk), + }; + } + if (status?.authSource === "app") { + return { + tokenTypeLabel: "GitHub App user token", + permissionMode: "app", + permissionHeading: "APP PERMISSIONS", + hasInspectableScopes: false, + repoAccessLabel: githubRepoAccessLabel(status.repoAccessOk), + }; + } + + const fineGrained = status?.tokenType === "fine-grained"; + const hasInspectableScopes = !fineGrained || (status?.scopes.length ?? 0) > 0; + return { + tokenTypeLabel: githubTokenTypeLabel(status), + permissionMode: fineGrained && !hasInspectableScopes ? "fine-grained" : "scopes", + permissionHeading: fineGrained && !hasInspectableScopes ? "TOKEN PERMISSIONS" : "DETECTED SCOPES", + hasInspectableScopes, + repoAccessLabel: githubRepoAccessLabel(status?.repoAccessOk), + }; +} + +function githubTokenTypeLabel(status: GitHubStatus | null): string { + return status?.tokenType === "classic" + ? "Classic PAT" + : status?.tokenType === "fine-grained" + ? "Fine-grained PAT" + : status?.tokenType === "oauth" + ? "OAuth token" + : status?.tokenType === "unknown" + ? "Unknown token" + : "N/A"; +} + +function githubRepoAccessLabel(repoAccessOk: boolean | null | undefined): string { + if (repoAccessOk === true) return "Repository metadata access verified"; + if (repoAccessOk === false) return "Repository access unavailable"; + return "Repository access not checked"; +} + /** * Honest, two-axis derivation of ADE's GitHub App integration health. * diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index d6619363e..5211eb5c6 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -486,8 +486,8 @@ export type LaneDeleteEvent = { }; /** - * Fired once when a lane reaches a terminal lifecycle transition, so the - * renderer can surface a toast without polling. Distinct from + * Fired once when a lane reaches a lifecycle transition, or when its persisted + * branch identity changes and every renderer surface must refresh. Distinct from * {@link LaneDeleteEvent}, which streams per-step delete progress; this fires a * single time on successful completion. `lane` carries the full summary for * created lanes (the create paths already have it); archive/delete/rename and @@ -498,7 +498,7 @@ export type LaneLifecycleEvent = { type: | "lane-created" | "lane-renamed" - | "lane-updated" + | "lane-branch-updated" | "lane-archived" | "lane-unarchived" | "lane-reclaimed" diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index dfaee16d2..900d71204 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -544,7 +544,7 @@ fork points. | `laneService.listBranchProfiles(laneId)` | Returns every branch profile recorded for the lane plus the active branch (auto-upserts a profile for the lane's current `branch_ref` so the active branch is always present). | | `laneService.previewBranchSwitch(args)` | Pure read: dirty-tree probe, duplicate-owner detection (another lane already on that branch), active terminal/process inventory, base-ref/parent inference, remote-prefix stripping. Used to drive the iOS/desktop branch picker confirmation UI. | | `laneService.switchBranch(args)` | Performs the checkout: refuses dirty trees, refuses duplicate-owner branches, requires `acknowledgeActiveWork` if active sessions/processes exist, then `git checkout` (or `checkout -b` in `mode: "create"`), updates the lane row, upserts the branch profile, and prunes stale `pull_requests` rows whose `head_branch` no longer matches the new branch. (`pull_requests.lane_id` is `not null`, so stale rows are deleted along with their child rows in `pr_group_members`.) | -| `laneService.updateBranchRef(laneId, branchRef)` | Internal helper used after rename/import paths to keep the active profile and `lanes.branch_ref` in sync. After the transaction commits it emits the refresh-only `lane-updated` lifecycle event so Work hover details, Lanes, and both Git Actions panes replace stale branch identity immediately. | +| `laneService.updateBranchRef(laneId, branchRef)` | Internal helper used after rename/import paths to keep the active profile and `lanes.branch_ref` in sync. After the transaction commits it emits the refresh-only `lane-branch-updated` lifecycle event so Work hover details, Lanes, and both Git Actions panes replace stale branch identity immediately. | IPC channels (registered in `services/ipc/registerIpc.ts`, exposed via `preload.ts`): @@ -680,7 +680,7 @@ Lane management (selected): | `ade.lanes.delete.risk` | `(args: { laneId }) => LaneDeleteRisk` — preflight read for the manage dialog: dirty state, unpushed commit count, remote-branch existence, active PTYs/watchers, env-init flag. | | `ade.lanes.delete.cancel` | `(args: { laneId }) => { cancelled, reason? }` — cooperative cancel during the early teardown steps. After `git_worktree_remove` starts the lane is unrecoverable and cancel is a no-op. | | `ade.lanes.delete.event` (push) | `LaneDeleteEvent` carrying `LaneDeleteProgress` — `steps[]` with per-step status (`pending` / `running` / `completed` / `failed` / `skipped`) plus `overallStatus` (`running` / `completed` / `failed` / `cancelled`) and `cancellable`. | -| `ade.lanes.lifecycle.event` (push) | `LaneLifecycleEvent` - one-shot `lane-created`, `lane-renamed`, refresh-only `lane-updated`, `lane-archived`, `lane-reclaimed`, `lane-unarchived`, `lane-restored`, or `lane-deleted` event. Auto identity emits `lane-updated` only after the renamed branch is persisted; `useLaneListInvalidation` refreshes every lane consumer and `useLaneEventToasts` intentionally ignores this internal event. Local desktop paths emit this IPC channel directly; runtime-backed paths push `lane_lifecycle_event`, and preload merges both sources behind `window.ade.lanes.onLifecycleEvent`. | +| `ade.lanes.lifecycle.event` (push) | `LaneLifecycleEvent` - one-shot `lane-created`, `lane-renamed`, refresh-only `lane-branch-updated`, `lane-archived`, `lane-reclaimed`, `lane-unarchived`, `lane-restored`, or `lane-deleted` event. Auto identity emits `lane-branch-updated` only after the renamed branch is persisted; `useLaneListInvalidation` refreshes every lane consumer and `useLaneEventToasts` intentionally ignores this internal event. Local desktop paths emit this IPC channel directly; runtime-backed paths push `lane_lifecycle_event`, and preload merges both sources behind `window.ade.lanes.onLifecycleEvent`. | | `ade.lanes.delete.progress.list` | replay of the in-memory `LaneDeleteProgress` map for currently running deletes. Completed delete results are delivered through the live event stream; a remount after completion refreshes the lane list instead of replaying historical progress. | | `ade.lanes.getBranchDrift` | `(args: { laneId: string }) => LaneBranchDrift \| null` — fresh HEAD read for callers about to act on the branch; `null` for archived lanes, an unavailable worktree, a detached HEAD, or no drift. See [Branch drift](#branch-drift). | | `ade.lanes.resolveBranchDrift` | `(args: ResolveLaneBranchDriftArgs) => ResolveLaneBranchDriftResult` — `switch-back` checks the worktree back onto the recorded branch; `keep-head` adopts the live HEAD (and renames a branch-advertising lane name) in one transaction. | diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 2c6211dab..ff875ee5f 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -75,9 +75,11 @@ Main process: `githubRateLimit.ts` — GitHub App, environment, PAT, and GitHub CLI credential discovery; `/user` and repository probes; structured auth-failure classification; and REST quota parsing. Explicit environment - tokens override all stored credentials for automation. Otherwise async REST - operations prefer the authorized ADE GitHub App, so the desktop and bundled - CLI do not require a second PAT or `gh` login. `GitHubStatus.authFailure` + tokens override all stored credentials for automation. Stored PATs and local + GitHub CLI auth remain ahead of ADE GitHub App authorization for REST + operations, so a read-only App installation cannot silently replace a + write-capable local credential. App authorization remains the final fallback. + `GitHubStatus.authFailure` distinguishes rate limiting, invalid credentials, network failures, and unknown validation errors so clients do not flatten every failed probe into missing permissions. @@ -242,13 +244,18 @@ Renderer — settings: choice. See [logging and product analytics](../../logging.md). - `apps/desktop/src/renderer/components/settings/GitHubIntegrationSection.tsx` and `GitHubSection.tsx` — ADE GitHub App / environment / GitHub CLI / PAT - auth, scope diagnostics, - permission guidance, structured validation failures, and the latest GitHub - REST quota. Embedded inside General. A rate-limited credential renders - **Rate limited**, the reset time/quota, and no auth command; only a missing, - invalid, or genuinely under-scoped credential shows login/refresh - instructions. Raw network/unknown validation errors stay in Settings rather - than the global banner. The shared + auth, credential-specific permission diagnostics, structured validation + failures, and the latest GitHub REST quota. Embedded inside General. Classic + PATs and CLI OAuth tokens show their detected scopes; fine-grained PATs show + repository-permission guidance; App user tokens show installation-backed + repository metadata access and never report missing classic `repo` / + `workflow` scopes, because GitHub Apps do not use those OAuth scopes. The App + panel also states that this repository probe does not prove write access and + that App authorization is the final credential fallback. A rate-limited + credential renders **Rate limited**, the reset time/quota, and no auth + command; only a missing, invalid, or genuinely under-scoped credential shows + login/refresh instructions. Raw network/unknown validation errors stay in + Settings rather than the global banner. The shared `renderer/lib/githubIntegrationStatus.ts` presentation helper keeps banner and Settings classification aligned. This section also hosts the `GitHubAppInstallPanel` (below) for installing "ADE for GitHub". From 63b9a8971cb8dd8d645e4ab7fcd2a2e1012e19d8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:30:57 -0400 Subject: [PATCH 3/9] test(orchestration): stabilize deferred retry timing --- .../services/ai/tools/orchestrationTools.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts b/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts index 547b721f3..8774e1b09 100644 --- a/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts @@ -317,6 +317,7 @@ describe("spawnAgent tool", () => { let setup: Setup; afterEach(async () => { if (setup) await cleanup(setup); + vi.useRealTimers(); }); it("rejects briefs missing required sections", async () => { @@ -471,8 +472,10 @@ describe("spawnAgent tool", () => { }); it("redelivers a backoff-deferred brief via a self-armed timer with no further mutation", async () => { - // Real service clock (no injected `now`) so the deferred-retry timer's - // wall-clock delay matches the service's dueness check. + // Keep the service clock and deferred-retry timer on the same deterministic + // clock. A real-time wait here is load-sensitive when the full CI shard is + // contending for the event loop. + vi.useFakeTimers(); setup = await setupWithRun("lead"); await approveRun(setup); // Fail the first delivery, then succeed on the timer-driven retry. @@ -499,10 +502,11 @@ describe("spawnAgent tool", () => { expect(briefStatus()).toBe("pending"); expect(setup.chat.sendMessage).toHaveBeenCalledTimes(1); - // Deliberately issue NO further tool call / mutation. The timer armed by the - // deferred drain must retry on its own once the (500ms) backoff elapses. + // Deliberately issue NO further tool call / mutation. Advancing only time + // proves the deferred drain retries on its own once the backoff elapses. + await vi.advanceTimersByTimeAsync(500); await vi.waitFor(() => expect(briefStatus()).toBe("delivered"), { - timeout: 5000, + timeout: 1000, interval: 25, }); expect(setup.chat.sendMessage).toHaveBeenCalledTimes(2); From 8211e2d5e7a5668853f398f0a17f1389f364f9f4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:35:41 -0400 Subject: [PATCH 4/9] fix(work): correct auth and attention lifecycle --- CHANGELOG.md | 3 +- apps/ade-cli/src/cli.ts | 5 +- .../src/headlessLinearServices.test.ts | 3 +- apps/ade-cli/src/headlessLinearServices.ts | 117 ++++++++---------- .../sync/syncRemoteCommandService.test.ts | 6 +- .../services/sync/syncRemoteCommandService.ts | 3 +- .../ade-cli-control-plane/SKILL.md | 42 ++++++- .../services/chat/agentChatService.test.ts | 36 +++++- .../main/services/chat/agentChatService.ts | 18 ++- .../services/github/githubService.test.ts | 45 ++++--- .../src/main/services/github/githubService.ts | 94 +++++++------- .../src/main/services/ipc/registerIpc.ts | 2 +- .../sync/syncRemoteCommandService.test.ts | 13 +- .../github/GitHubAppInstallPanel.tsx | 7 ++ .../components/settings/GitHubSection.tsx | 12 +- .../lib/githubIntegrationStatus.test.ts | 10 ++ .../renderer/lib/githubIntegrationStatus.ts | 7 ++ .../desktop/src/shared/adeCliGuidance.test.ts | 22 ++++ apps/desktop/src/shared/adeCliGuidance.ts | 5 +- .../src/shared/githubOperationCredential.ts | 39 ++++++ .../onboarding-and-settings/README.md | 19 +-- .../features/terminals-and-sessions/README.md | 4 +- 22 files changed, 342 insertions(+), 170 deletions(-) create mode 100644 apps/desktop/src/shared/githubOperationCredential.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 226a5e6d7..790fc3d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Work and reliability - Show an animated `Naming lane…` placeholder while automatic lane identity is still resolving, reveal the deterministic fallback only on failure, refresh renamed branches live across Lanes, Git Actions, and session hover details, and keep sidebar Working durations anchored to each chat turn. -- Keep write-capable environment, personal access token, and GitHub CLI credentials ahead of read-only GitHub App authorization, and show App installation permissions without false classic OAuth scope errors. +- Prefer explicit environment and GitHub CLI credentials before a stored PAT, keep the GitHub App read-only and dedicated to real-time PR updates, avoid false classic OAuth scope errors for App authorization, and render App rate limits as a concise cooldown instead of a raw relay error. +- Clear an explicit `Needs you` hand-raise when the user replies during an active turn, keep agent-to-agent steers from dismissing it, and give bundled agents concrete `note` / `ask` / snooze lifecycle rules so blocked idle chats do not appear Done. ## [1.2.46] - 2026-07-30 diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 485f95388..333683bcb 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1216,8 +1216,9 @@ const HELP_BY_COMMAND: Record = { - login keeps one connection open for the whole device flow because the device-auth session lives in runtime memory; do not split start and poll across separate invocations in headless mode. - - GitHub operations prefer environment tokens, stored PATs, and GitHub CLI - auth in that order; GitHub App user authorization is the fallback. + - GitHub operations prefer an explicit environment token, then GitHub CLI, + and finally a stored PAT. The GitHub App remains read-only and is used + only for webhook-backed PR updates. Flags (login): --max-wait Give up waiting after N seconds (default: GitHub's diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 59eeda07e..551328919 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -669,7 +669,7 @@ describe("headlessLinearServices", () => { } }); - it("keeps a stored machine PAT ahead of GitHub App authorization for async REST calls", async () => { + it("keeps the read-only GitHub App out of operational REST credential selection", async () => { const previousAdeHome = process.env.ADE_HOME; const previousFetch = globalThis.fetch; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-")); @@ -739,6 +739,7 @@ describe("headlessLinearServices", () => { "github.com:\n oauth_token: gho_cli_token\n", ); const machineCredentialStore = new EncryptedFileCredentialStore(); + machineCredentialStore.setSync("github.token.v1", "ghp_stored_token"); machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_app_user_token", tokenType: "bearer", diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 4732d8bf8..fb11146bc 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -55,6 +55,10 @@ import { createFileService as createFileServiceImpl } from "../../desktop/src/ma import { createPrService as createPrServiceImpl } from "../../desktop/src/main/services/prs/prService"; import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { + selectGithubOperationCredential, + selectGithubOperationCredentialAsync, +} from "../../desktop/src/shared/githubOperationCredential"; import { linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, @@ -571,32 +575,32 @@ export function createHeadlessGitHubService( }; const readToken = (): HeadlessGitHubTokenLookup => { - const env = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); - if (env) { - return { - token: env, - source: "environment", - patTokenStored: false, - ghCliPath: null, - ghAuthError: null, - }; - } - const patToken = readStoredPatToken(); - if (patToken) { - return { - token: patToken, - source: "pat", - patTokenStored: true, - ghCliPath: null, - ghAuthError: null, - }; - } - const gh = ghAuthToken(); - return { - ...gh, - source: gh.token ? "gh" : "none", + let ghFallback: HeadlessGitHubTokenLookup = { + token: null, + source: "none", patTokenStored: false, + ghCliPath: null, + ghAuthError: null, }; + return selectGithubOperationCredential({ + environment: () => { + const token = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); + return token + ? { token, source: "environment", patTokenStored: false, ghCliPath: null, ghAuthError: null } + : null; + }, + gh: () => { + const gh = ghAuthToken(); + ghFallback = { ...gh, source: "none", patTokenStored: false }; + return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; + }, + pat: () => { + const token = readStoredPatToken(); + return token + ? { token, source: "pat", patTokenStored: true, ghCliPath: null, ghAuthError: null } + : null; + }, + }) ?? ghFallback; }; const readStoredPatTokenAsync = async (): Promise => { @@ -612,51 +616,32 @@ export function createHeadlessGitHubService( }; const readTokenAsync = async (): Promise => { - const env = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); - if (env) { - return { - token: env, - source: "environment", - patTokenStored: false, - ghCliPath: null, - ghAuthError: null, - }; - } - const patToken = await readStoredPatTokenAsync(); - if (patToken) { - return { - token: patToken, - source: "pat", - patTokenStored: true, - ghCliPath: null, - ghAuthError: null, - }; - } - const gh = await ghAuthTokenAsync(); - if (gh.token) { - return { - ...gh, - source: "gh", - patTokenStored: false, - }; - } - const appToken = appUserAuth.getAuthStatus().tokenStored - ? await appUserAuth.getValidTokenForRelay().catch(() => null) - : null; - if (appToken) { - return { - token: appToken, - source: "app", - patTokenStored: false, - ghCliPath: null, - ghAuthError: null, - }; - } - return { - ...gh, + let ghFallback: HeadlessGitHubTokenLookup = { + token: null, source: "none", patTokenStored: false, + ghCliPath: null, + ghAuthError: null, }; + return await selectGithubOperationCredentialAsync({ + environment: () => { + const token = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); + return token + ? { token, source: "environment", patTokenStored: false, ghCliPath: null, ghAuthError: null } + : null; + }, + gh: async () => { + const gh = await ghAuthTokenAsync(); + ghFallback = { ...gh, source: "none", patTokenStored: false }; + return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; + }, + pat: async () => { + const token = await readStoredPatTokenAsync(); + return token + ? { token, source: "pat", patTokenStored: true, ghCliPath: null, ghAuthError: null } + : null; + }, + }) ?? ghFallback; }; const getToken = (): string => readToken().token ?? ""; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index d223f9b38..55fc17f92 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1302,16 +1302,16 @@ describe("createSyncRemoteCommandService", () => { }); it("preserves Claude priority steering and guarded queue cancellation", async () => { - const steer = vi.fn().mockResolvedValue({ steerId: "steer-1", queued: false }); + const steerUserMessage = vi.fn().mockResolvedValue({ steerId: "steer-1", queued: false }); const cancelSteer = vi.fn().mockResolvedValue(undefined); - const { service } = createService({ agentChatService: { steer, cancelSteer } }); + const { service } = createService({ agentChatService: { steerUserMessage, cancelSteer } }); await expect(service.execute(makePayload("chat.steer", { sessionId: "chat-1", text: "Redirect the active turn.", dispatchMode: "interrupt", }))).resolves.toEqual({ ok: true, steerId: "steer-1", queued: false }); - expect(steer).toHaveBeenCalledWith({ + expect(steerUserMessage).toHaveBeenCalledWith({ sessionId: "chat-1", text: "Redirect the active turn.", dispatchMode: "interrupt", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index d8797b70e..d083b97e6 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -4423,7 +4423,8 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio requireService(args.agentChatService, "Agent chat service not available.") .resolveUnprocessedMessage(parseAgentChatResolveUnprocessedMessageArgs(payload))); register("chat.steer", { viewerAllowed: true, queueable: false }, async (payload) => { - const result = await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); + const result = await requireService(args.agentChatService, "Agent chat service not available.") + .steerUserMessage(parseAgentChatSteerArgs(payload)); return isRecord(result) ? { ...result, ok: true } : { ok: true }; }); register("chat.cancelSteer", { viewerAllowed: true, queueable: false }, async (payload) => { diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 048b8702f..dbf7254de 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -174,7 +174,7 @@ background a raw CLI and then guess at its state: ### Session lifecycle: snooze, wake (settling is not yours) -**You cannot settle a session.** `ade chat settle`, `ade chat unsettle`, +**You cannot settle or unsettle a session.** `ade chat settle`, `ade chat unsettle`, `ade session settle`, and `ade session unsettle` were removed: whether work is actually finished is a subjective judgment, and a chat that settles itself disappears from the user's active list on your say-so. A row leaves the active @@ -186,6 +186,46 @@ What to do instead when you finish: say so in your final message, and use `ade chat note ""` to leave a durable status line on the Work row. If you are blocked, `ade chat ask ""` raises the row's hand. +#### Work-row status protocol + +Treat the status line and hand-raise as separate signals: + +- **`ade chat note` explains the current state.** Write one concrete, + present-tense sentence containing the work or result and the next dependency. + Good: `CI is green; waiting for Codex review on commit 8f21a4c.` + Bad: `Working`, `Still looking`, `Blocked`, or `Done`. +- **`ade chat ask` means work cannot continue without a user answer.** Ask the + exact question that unlocks the next action. Include the meaningful choices + and consequence when there is a tradeoff. +- **When user input blocks progress, call `note` and then `ask`.** The note + preserves the operational context; the ask raises the Work row to **Needs + you**. A note alone never changes the canonical phase and an idle row can + otherwise appear **Done**. +- **The next accepted user message clears the prior hand-raise.** While the + reply is being handled, the row should be **Working**, not **Needs you**. If + the reply resolves the blocker, continue normally. If it does not, leave an + updated note and call `ask` again with the exact information still missing + before ending the turn. +- **Do not use `ask` for external waiting.** If CI, review, a build, or another + service is still running and no user action is required, leave a specific + note and either keep polling or snooze the session. +- **Do not ask the user to classify a failure the agent can investigate.** + Diagnose and recover autonomously. Raise an ask only when recovery requires + new authority, unavailable credentials, or a product choice. + +| Situation | Required action | Example | +|---|---|---| +| Actively working | `note` when the phase materially changes | `Reproduced the branch-refresh bug; updating the shared lane projection.` | +| Waiting on external work | `note`, then poll or snooze | `PR #977 CI is running on 8 shards; next poll is scheduled in 12 minutes.` | +| Blocked on user input | `note`, then `ask` | Note: `Two migration strategies preserve existing data; implementation is paused.` Ask: `Use the reversible in-place migration, or create a new store and copy records?` | +| Recoverable error | `note`, investigate, continue | `Desktop shard 7 timed out without a failed assertion; rerunning that shard.` | +| Unrecoverable error needing user action | `note`, then `ask` | Note: `GitHub rejected the push because no writable credential is available.` Ask: `Authenticate gh, or should I use the stored PAT?` | +| Delivered | final response plus `note` | `PR #977 merged; automatic lane naming and live branch refresh are shipped.` | + +Before ending any non-delivered turn, ask: **Can useful work continue without +the user?** If yes, continue or snooze—do not hand-raise. If no, ensure both a +specific note and an exact ask were sent. + Snooze is the lifecycle verb you *do* own. The typed family takes the session id as a positional, also accepts `--session`, and falls back to `ADE_CHAT_SESSION_ID` when you omit it. diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e0f82593d..7f02e6cd2 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -25091,7 +25091,7 @@ describe("createAgentChatService", () => { it("tracks accepted Codex follow-ups until the app-server proves they were processed", async () => { const events: AgentChatEventEnvelope[] = []; - const { service } = createService({ + const { service, sessionService } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), }); const session = await service.createSession({ @@ -25101,7 +25101,12 @@ describe("createAgentChatService", () => { }); await service.sendMessage({ sessionId: session.id, text: "Start." }, { awaitDispatch: true }); - const first = await service.steer({ sessionId: session.id, text: "First follow-up." }); + sessionService.clearTurnStartMarkers.mockClear(); + const first = await service.steerUserMessage({ + sessionId: session.id, + text: "First follow-up.", + }); + expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledWith(session.id); expect(events.some((event) => event.event.type === "user_message" && event.event.steerId === first.steerId @@ -25109,6 +25114,31 @@ describe("createAgentChatService", () => { && event.event.processed === false )).toBe(true); + sessionService.clearTurnStartMarkers.mockClear(); + mockState.codexResponseOverrides.set("turn/steer", { + error: { code: -32603, message: "provider rejected steer" }, + }); + await expect(service.sendMessage({ + sessionId: session.id, + text: "Rejected follow-up.", + }, { + routeActiveToSteer: true, + })).rejects.toThrow("provider rejected steer"); + expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + mockState.codexResponseOverrides.delete("turn/steer"); + + await service.steerUserMessage({ + sessionId: session.id, + text: " ", + }); + expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + + await service.steer({ + sessionId: session.id, + text: "Agent-originated follow-up.", + }); + expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + mockState.emitCodexPayload({ method: "item/started", params: { @@ -25129,7 +25159,9 @@ describe("createAgentChatService", () => { )).toBe(true); }); + sessionService.clearTurnStartMarkers.mockClear(); const second = await service.steer({ sessionId: session.id, text: "Second follow-up." }); + expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); mockState.emitCodexPayload({ method: "turn/aborted", params: { turnId: "turn-1" }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 571bc5a6c..753738bb7 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -34937,8 +34937,7 @@ export function createAgentChatService(args: { } }; if (options?.routeActiveToSteer && routableText && canRouteActiveSendToSteer(managed)) { - clearUserTurnMarkers(); - return steer({ + return steerUserMessage({ sessionId: args.sessionId, text: args.text, displayText: args.displayText, @@ -35156,7 +35155,6 @@ export function createAgentChatService(args: { if (hasLivePendingInput(managed) && !metadata?.scheduledWake && !options?.allowPendingInput) { throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE); } - // OpenCode runtime steer if (managed.runtime?.kind === "opencode") { const runtime = managed.runtime; @@ -35528,6 +35526,19 @@ export function createAgentChatService(args: { const steer = async (args: AgentChatSteerArgs): Promise => await steerWithOptions(args); + const steerUserMessage = async ( + args: AgentChatSteerArgs, + ): Promise => { + const routableMessage = args.text.trim().length > 0 + || (args.attachments?.length ?? 0) > 0 + || (args.contextAttachments?.length ?? 0) > 0; + const result = await steerWithOptions(args); + if (routableMessage && result.reason !== "queue_full" && !args.metadata?.scheduledWake) { + sessionService.clearTurnStartMarkers(args.sessionId); + } + return result; + }; + const normalizeMessageSessionKind = ( kind: AgentChatMessageSessionArgs["kind"], ): AgentChatMessageSessionKind => { @@ -42552,6 +42563,7 @@ export function createAgentChatService(args: { clearCodexGoal, runSessionTurn, steer, + steerUserMessage, cancelSteer, editSteer, dispatchSteer, diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index f41e37de8..339df3689 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -750,12 +750,32 @@ describe("githubService.getStatus", () => { it("classic token with required scopes is connected (no repo probe needed)", async () => { stubOriginRemote(); process.env.GITHUB_TOKEN = "ghp_classic"; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_stored_token"); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); mockFetch.mockResolvedValueOnce( jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), ); - const status = await makeService().getStatus(); + const status = await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).getStatus(); expect(status.tokenStored).toBe(true); + expect(status.authSource).toBe("environment"); expect(status.tokenType).toBe("classic"); expect(status.userLogin).toBe("alice"); expect(status.scopes).toEqual(["repo", "workflow"]); @@ -764,7 +784,7 @@ describe("githubService.getStatus", () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - it("keeps a stored PAT ahead of GitHub App authorization for async REST calls", async () => { + it("keeps the read-only GitHub App out of operational REST credential selection", async () => { stubOriginRemote(); const credentialStore = new MemoryCredentialStore(); credentialStore.setSync("github.token.v1", "ghp_stored_token"); @@ -801,6 +821,7 @@ describe("githubService.getStatus", () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_stored_token"); credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_app_user_token", tokenType: "bearer", @@ -837,7 +858,7 @@ describe("githubService.getStatus", () => { .toBe("Bearer gho_cli_token"); }); - it("falls back to GitHub App authorization when no local credential is available", async () => { + it("does not use a relay-only GitHub App token when no operation credential is available", async () => { stubOriginRemote(); const credentialStore = new MemoryCredentialStore(); credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ @@ -850,24 +871,16 @@ describe("githubService.getStatus", () => { userLogin: "alice", updatedAt: new Date().toISOString(), })); - mockFetch - .mockResolvedValueOnce(jsonResponse(200, { login: "alice" })) - .mockResolvedValueOnce(jsonResponse(200, { id: 1, full_name: "acme/ade" })); - const status = await makeService({ credentialStore }).getStatus(); expect(status).toMatchObject({ - authSource: "app", - connected: true, + authSource: "none", + connected: false, patTokenStored: false, - repoAccessOk: true, - userLogin: "alice", + repoAccessOk: null, + userLogin: null, }); - expect(mockFetch).toHaveBeenCalledTimes(2); - for (const [, init] of mockFetch.mock.calls as Array<[string, RequestInit]>) { - expect((init.headers as Record).authorization) - .toBe("Bearer ghu_app_user_token"); - } + expect(mockFetch).not.toHaveBeenCalled(); }); it("reports an exhausted GitHub API quota as rate limited instead of missing permissions", async () => { diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 1a0a36f17..c55945642 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -22,6 +22,10 @@ import { resolveAdeLayout } from "../../../shared/adeLayout"; import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; import { getGitHubTokenAccessState, parseGitHubScopeHeaders } from "../../../shared/githubScopes"; import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; +import { + selectGithubOperationCredential, + selectGithubOperationCredentialAsync, +} from "../../../shared/githubOperationCredential"; import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig"; import { createGitHubAppUserAuthService } from "./githubAppUserAuthService"; @@ -708,66 +712,54 @@ export function createGithubService({ : null; }; - const readPrimaryAuthToken = (): GitHubTokenLookup | null => - readEnvironmentAuthToken() ?? readPatAuthToken(); - const readAuthToken = async (): Promise => { - const primary = readPrimaryAuthToken(); - if (primary) return primary; - const gh = await readGhAuthToken(); - if (gh.token) { - return { - ...gh, - source: "gh", - patTokenStored: false, - }; - } - const appToken = appUserAuth.getAuthStatus().tokenStored - ? await appUserAuth.getValidTokenForRelay().catch(() => null) - : null; - if (appToken) { - return { - token: appToken, - source: "app", - patTokenStored: false, - ghCliPath: null, - ghAuthError: null, - }; - } - return { - ...gh, + let ghFallback: GitHubTokenLookup = { + token: null, source: "none", patTokenStored: false, + ghCliPath: null, + ghAuthError: null, }; + return await selectGithubOperationCredentialAsync({ + environment: () => readEnvironmentAuthToken(), + gh: async () => { + const gh = await readGhAuthToken(); + ghFallback = { ...gh, source: "none", patTokenStored: false }; + return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; + }, + pat: () => readPatAuthToken(), + }) ?? ghFallback; }; const readAuthTokenSync = (): GitHubTokenLookup => { - const primary = readPrimaryAuthToken(); - if (primary) return primary; - if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { - sharedGhAuth.authCache = null; - return { - token: null, - source: "none", - patTokenStored: false, - ghCliPath: null, - ghAuthError: null, - }; - } - const cachedGh = sharedGhAuth.authCache && sharedGhAuth.authCache.expiresAt > Date.now() - ? sharedGhAuth.authCache - : null; - const hostsToken = cachedGh?.token ? null : readGhHostsFileToken(); - const gh: GitHubCliAuthResult = cachedGh ?? { - token: hostsToken, - ghCliPath: null, - ghAuthError: hostsToken ? null : "GitHub auth has not been resolved yet.", - }; - return { - ...gh, - source: gh.token ? "gh" : "none", + let ghFallback: GitHubTokenLookup = { + token: null, + source: "none", patTokenStored: false, + ghCliPath: null, + ghAuthError: null, }; + return selectGithubOperationCredential({ + environment: () => readEnvironmentAuthToken(), + gh: () => { + if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { + sharedGhAuth.authCache = null; + return null; + } + const cachedGh = sharedGhAuth.authCache && sharedGhAuth.authCache.expiresAt > Date.now() + ? sharedGhAuth.authCache + : null; + const hostsToken = cachedGh?.token ? null : readGhHostsFileToken(); + const gh: GitHubCliAuthResult = cachedGh ?? { + token: hostsToken, + ghCliPath: null, + ghAuthError: hostsToken ? null : "GitHub auth has not been resolved yet.", + }; + ghFallback = { ...gh, source: "none", patTokenStored: false }; + return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; + }, + pat: () => readPatAuthToken(), + }) ?? ghFallback; }; const persistToken = (token: string | null): void => { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index ace2224b4..6ffda6a52 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -7161,7 +7161,7 @@ export function registerIpc({ ipcMain.handle(IPC.agentChatSteer, async (_event, arg: AgentChatSteerArgs): Promise => { const ctx = ensureAgentChatContext(); - return await ctx.agentChatService.steer(arg); + return await ctx.agentChatService.steerUserMessage(arg); }); ipcMain.handle(IPC.agentChatCancelSteer, async (_event, arg: unknown): Promise => { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index b40f2020b..8e8e7d669 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -229,6 +229,7 @@ function createMockAgentChatService() { interrupt: vi.fn().mockResolvedValue(undefined), restoreCancelledQueue: vi.fn().mockResolvedValue({ restored: false, restoredCount: 0 }), steer: vi.fn().mockResolvedValue(undefined), + steerUserMessage: vi.fn().mockResolvedValue(undefined), cancelSteer: vi.fn().mockResolvedValue(undefined), editSteer: vi.fn().mockResolvedValue(undefined), approveToolUse: vi.fn().mockResolvedValue(undefined), @@ -1683,12 +1684,12 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow("chat.interrupt requires sessionId."); }); - it("chat.steer routes to agentChatService.steer", async () => { + it("chat.steer routes human input to agentChatService.steerUserMessage", async () => { const result = await service.execute(makePayload("chat.steer", { sessionId: "sess-1", text: "change direction", })); - expect(agentChatService.steer).toHaveBeenCalledWith({ + expect(agentChatService.steerUserMessage).toHaveBeenCalledWith({ sessionId: "sess-1", text: "change direction", }); @@ -1696,7 +1697,7 @@ describe("createSyncRemoteCommandService", () => { }); it("chat.steer returns the backend queued result for mobile clients", async () => { - agentChatService.steer.mockResolvedValueOnce({ steerId: "steer-1", queued: true }); + agentChatService.steerUserMessage.mockResolvedValueOnce({ steerId: "steer-1", queued: true }); const result = await service.execute(makePayload("chat.steer", { sessionId: "sess-1", text: "change direction", @@ -1889,7 +1890,7 @@ describe("createSyncRemoteCommandService", () => { { path: "notes.txt", type: "file" }, ], })); - expect(agentChatService.steer).toHaveBeenCalledWith({ + expect(agentChatService.steerUserMessage).toHaveBeenCalledWith({ sessionId: "sess-1", text: "redirect", attachments: [ @@ -1900,13 +1901,13 @@ describe("createSyncRemoteCommandService", () => { }); it("omits attachments when array has no valid entries", async () => { - agentChatService.steer.mockClear(); + agentChatService.steerUserMessage.mockClear(); await service.execute(makePayload("chat.steer", { sessionId: "sess-1", text: "redirect", attachments: [{ path: "", type: "image" }, { type: "file" }], })); - const sent = agentChatService.steer.mock.calls[0][0] as Record; + const sent = agentChatService.steerUserMessage.mock.calls[0][0] as Record; expect(sent, "no valid attachments → key omitted").not.toHaveProperty("attachments"); expect(sent).toEqual({ sessionId: "sess-1", text: "redirect" }); }); diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx index 1384ed995..b1e4a177a 100644 --- a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx @@ -13,6 +13,7 @@ import { deriveGithubRepoConnectionState, githubAccountIssueCopy, githubRepoIssueCopy, + isGithubRateLimitMessage, isGithubRealtimeHealthy, isGithubRepoAccessPending, } from "../../lib/githubIntegrationStatus"; @@ -483,6 +484,12 @@ function repoView( subtext: "Checking whether ADE for GitHub is installed on this repo…", }; } + if (isGithubRateLimitMessage(error)) { + return { + pill: { tone: "warn", color: COLORS.warning, label: "Rate limited" }, + subtext: "GitHub temporarily paused automatic App checks. ADE is still authorized; wait for the cooldown, then recheck. Re-authorizing is not needed.", + }; + } return { pill: { tone: "neutral", color: COLORS.textMuted, label: "Couldn't verify" }, subtext: error?.trim() diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index 03fd160f7..b697fc460 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -32,14 +32,16 @@ const GH_AUTH_REFRESH_WITH_GIST_COMMAND = "gh auth refresh -h github.com -s repo const GITHUB_CLASSIC_TOKEN_NEW_URL = "https://github.com/settings/tokens/new?description=ADE%20desktop%20PR%20workflows&scopes=repo,workflow"; const GITHUB_CLASSIC_TOKEN_WITH_GIST_NEW_URL = "https://github.com/settings/tokens/new?description=ADE%20desktop%20PR%20workflows&scopes=repo,workflow,gist"; const GITHUB_CLASSIC_TOKENS_URL = "https://github.com/settings/tokens"; -const GITHUB_FINE_GRAINED_TOKEN_NEW_URL = "https://github.com/settings/personal-access-tokens/new?name=ADE&description=ADE%20desktop%20PR%20workflows&contents=write&pull_requests=write&metadata=read&actions=write&workflows=write"; +const GITHUB_FINE_GRAINED_TOKEN_NEW_URL = "https://github.com/settings/personal-access-tokens/new?name=ADE&description=ADE%20desktop%20PR%20workflows&contents=write&pull_requests=write&metadata=read&actions=write&checks=write&statuses=read&workflows=write"; const GITHUB_FINE_GRAINED_TOKENS_URL = "https://github.com/settings/personal-access-tokens"; -const REQUIRED_GITHUB_FINE_GRAINED_PERMISSIONS = [ +const REQUIRED_GITHUB_REPOSITORY_PERMISSIONS = [ "Contents: Read and write", "Pull requests: Read and write", "Metadata: Read", "Actions: Read and write", + "Checks: Read and write", + "Commit statuses: Read", "Workflows: Write", ] as const; @@ -386,12 +388,12 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { {credentialPresentation.repoAccessLabel}
- GitHub App user tokens do not use classic OAuth scopes. Their capabilities come from the ADE for GitHub installation, and the repository check above verifies metadata access only—not write permissions. ADE prefers environment, personal access token, and GitHub CLI credentials for GitHub operations, and uses this App authorization only as a fallback. + The ADE GitHub App is intentionally read-only and is used only for webhook-backed, real-time pull request updates. GitHub operations use an explicit environment token first, then GitHub CLI, and finally a stored PAT.
) : permissionMode === "fine-grained" ? (
- {REQUIRED_GITHUB_FINE_GRAINED_PERMISSIONS.map((permission) => ( + {REQUIRED_GITHUB_REPOSITORY_PERMISSIONS.map((permission) => (
{permission} @@ -564,7 +566,7 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) {
- {REQUIRED_GITHUB_FINE_GRAINED_PERMISSIONS.map((perm) => ( + {REQUIRED_GITHUB_REPOSITORY_PERMISSIONS.map((perm) => ( { }); }); }); + +describe("isGithubRateLimitMessage", () => { + it("recognizes primary and secondary GitHub throttling without treating ordinary errors as limits", () => { + expect(isGithubRateLimitMessage("API rate limit exceeded for user ID 123.")).toBe(true); + expect(isGithubRateLimitMessage("You have exceeded a secondary rate limit.")).toBe(true); + expect(isGithubRateLimitMessage("Bad credentials")).toBe(false); + expect(isGithubRateLimitMessage(null)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts index da7583e2b..f891595c7 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts @@ -224,6 +224,13 @@ export function githubRepoIssueCopy( }; } +export function isGithubRateLimitMessage(message: string | null | undefined): boolean { + const normalized = message?.trim().toLowerCase() ?? ""; + return normalized.includes("rate limit") + || normalized.includes("abuse detection") + || normalized.includes("temporarily blocked from content creation"); +} + /** * Copy for the gh CLI/token banner — three sub-states, each with a stable * fingerprint so a change between them resurfaces a previously-dismissed banner. diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index 91fde7af2..e80b5aa18 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { adeBundledAgentSkills, buildAdeBootstrapGuidance, buildAdeCliAgentGuidance } from "./adeCliGuidance"; @@ -35,6 +37,8 @@ describe("ADE bootstrap guidance", () => { expect(bootstrap).toContain("tracked provider CLIs"); expect(bootstrap).toContain('ade chat note "running e2e shard 2/4"'); expect(bootstrap).toContain('ade chat ask ""'); + expect(bootstrap).toContain("a note alone can leave an idle row looking Done"); + expect(bootstrap).toContain("The next accepted user message clears the prior hand-raise"); // Settlement is user- and PR-merge-driven only: the bootstrap must tell the // agent it CANNOT settle, and must never hand it a settle command again. expect(bootstrap).toContain("You cannot settle or unsettle a session"); @@ -58,4 +62,22 @@ describe("ADE bootstrap guidance", () => { expect(bootstrap).not.toContain("### Minimum operating rules"); expect(bootstrap).not.toContain("--socket"); }); + + it("keeps the bundled control-plane skill aligned with the bootstrap lifecycle contract", () => { + const bootstrap = buildAdeBootstrapGuidance(roots); + const skill = fs.readFileSync(fileURLToPath(new URL( + "../../resources/agent-skills/ade-cli-control-plane/SKILL.md", + import.meta.url, + )), "utf8"); + + for (const invariant of [ + "ade chat note", + "ade chat ask", + "next accepted user message clears the prior hand-raise", + "You cannot settle or unsettle a session", + ]) { + expect(bootstrap.toLowerCase()).toContain(invariant.toLowerCase()); + expect(skill.toLowerCase()).toContain(invariant.toLowerCase()); + } + }); }); diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 2e95c9611..9a6bc0c4e 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -25,8 +25,9 @@ export const adeBundledAgentSkills = [ */ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "ADE control protocol for truthful Work status:", - '- Working: `ade chat note "running e2e shard 2/4"`; keep it current.', - '- Blocked on user input without a structured ask: `ade chat ask ""`.', + '- Working: `ade chat note "running e2e shard 2/4"`; state the concrete work and next dependency, never just "Working" or "Blocked".', + '- Blocked on user input: first `ade chat note ""`, then `ade chat ask ""`; a note alone can leave an idle row looking Done.', + "- The next accepted user message clears the prior hand-raise. If it does not resolve the blocker, update the note and call `ade chat ask` again before ending the turn.", '- Done: say so in your final message and leave a durable one-line result via `ade chat note ""`.', "- You cannot settle or unsettle a session; `ade chat settle` / `ade session settle` no longer exist. Filing a row as done is the user's call, or the automatic result of its PR merging.", "- Waiting on something you expect to take a while? `ade session snooze --for ` quiets the row without claiming the work is done, and a hand-raise wakes it early.", diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts new file mode 100644 index 000000000..be862cd6d --- /dev/null +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -0,0 +1,39 @@ +export const GITHUB_OPERATION_CREDENTIAL_PRECEDENCE = [ + "environment", + "gh", + "pat", +] as const; + +type GithubOperationCredentialSource = + (typeof GITHUB_OPERATION_CREDENTIAL_PRECEDENCE)[number]; + +type CredentialResolvers = Record< + GithubOperationCredentialSource, + () => T | null +>; + +type AsyncCredentialResolvers = Record< + GithubOperationCredentialSource, + () => T | null | Promise +>; + +export function selectGithubOperationCredential( + resolvers: CredentialResolvers, +): T | null { + for (const source of GITHUB_OPERATION_CREDENTIAL_PRECEDENCE) { + const credential = resolvers[source](); + if (credential) return credential; + } + return null; +} + +export async function selectGithubOperationCredentialAsync( + resolvers: AsyncCredentialResolvers, +): Promise { + for (const source of GITHUB_OPERATION_CREDENTIAL_PRECEDENCE) { + const candidate = resolvers[source](); + const credential = candidate instanceof Promise ? await candidate : candidate; + if (credential) return credential; + } + return null; +} diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index ff875ee5f..fffcb31af 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -75,10 +75,10 @@ Main process: `githubRateLimit.ts` — GitHub App, environment, PAT, and GitHub CLI credential discovery; `/user` and repository probes; structured auth-failure classification; and REST quota parsing. Explicit environment - tokens override all stored credentials for automation. Stored PATs and local - GitHub CLI auth remain ahead of ADE GitHub App authorization for REST - operations, so a read-only App installation cannot silently replace a - write-capable local credential. App authorization remains the final fallback. + tokens override all stored credentials for automation. Otherwise REST + operations prefer local GitHub CLI auth and then a stored PAT. The ADE GitHub + App remains a separate, read-only credential used only for webhook-backed + real-time PR updates. `GitHubStatus.authFailure` distinguishes rate limiting, invalid credentials, network failures, and unknown validation errors so @@ -250,12 +250,15 @@ Renderer — settings: repository-permission guidance; App user tokens show installation-backed repository metadata access and never report missing classic `repo` / `workflow` scopes, because GitHub Apps do not use those OAuth scopes. The App - panel also states that this repository probe does not prove write access and - that App authorization is the final credential fallback. A rate-limited + panel also states that the App is intentionally read-only and separate from + operation credentials, and documents the environment → GitHub CLI → PAT + order. A rate-limited credential renders **Rate limited**, the reset time/quota, and no auth command; only a missing, invalid, or genuinely under-scoped credential shows - login/refresh instructions. Raw network/unknown validation errors stay in - Settings rather than the global banner. The shared + login/refresh instructions. The App-installation card also classifies relay + rate-limit responses as a concise cooldown state instead of displaying + GitHub's raw request-id / scraping-policy error. Raw network/unknown + validation errors stay in Settings rather than the global banner. The shared `renderer/lib/githubIntegrationStatus.ts` presentation helper keeps banner and Settings classification aligned. This section also hosts the `GitHubAppInstallPanel` (below) for installing "ADE for GitHub". diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 8bc8c1a5b..86f1bc689 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -1271,7 +1271,9 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. turn streams, retains `settledAt`, and returns to Settled when it rests. `ade chat ask` clears settle, persists the blocking question and its `agent_explicit` provenance, marks a live tracked CLI as waiting-input, and - publishes a time-sensitive push; the next user turn clears it. Provider + publishes a time-sensitive push; the next accepted user message clears it, + including an active-turn steer. Agent-to-agent and orchestration steers do + not dismiss the user's pending question. Provider structured input carries its own pending item id. OSC markers and prompt-looking output never create `Needs you`. `ade chat note ""` clears only the status line. From 96170b4c4da3db55c75e0e1b5e4300fc4263a2a6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:52:26 -0400 Subject: [PATCH 5/9] fix(work): keep status notes glanceable --- CHANGELOG.md | 1 + apps/ade-cli/README.md | 2 +- apps/ade-cli/src/cli.ts | 2 +- .../src/services/sync/rosterBuilder.test.ts | 8 ++++-- .../src/services/sync/rosterBuilder.ts | 3 ++- apps/desktop/resources/ade-cli-help.txt | 2 +- .../ade-cli-control-plane/SKILL.md | 17 ++++++------ .../services/ai/tools/systemPrompt.test.ts | 2 +- .../chat/cursorSdkSystemPrompt.test.ts | 2 +- .../services/sessions/sessionService.test.ts | 27 +++++++++++++++++-- .../main/services/sessions/sessionService.ts | 9 ++++--- .../desktop/src/shared/adeCliGuidance.test.ts | 4 ++- apps/desktop/src/shared/adeCliGuidance.ts | 2 +- apps/desktop/src/shared/sessionStatusNote.ts | 20 ++++++++++++++ apps/desktop/src/shared/types/sessions.ts | 5 ++-- docs/features/agents/README.md | 3 ++- .../features/terminals-and-sessions/README.md | 4 ++- 17 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/shared/sessionStatusNote.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 790fc3d18..ed5be933c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show an animated `Naming lane…` placeholder while automatic lane identity is still resolving, reveal the deterministic fallback only on failure, refresh renamed branches live across Lanes, Git Actions, and session hover details, and keep sidebar Working durations anchored to each chat turn. - Prefer explicit environment and GitHub CLI credentials before a stored PAT, keep the GitHub App read-only and dedicated to real-time PR updates, avoid false classic OAuth scope errors for App authorization, and render App rate limits as a concise cooldown instead of a raw relay error. - Clear an explicit `Needs you` hand-raise when the user replies during an active turn, keep agent-to-agent steers from dismissing it, and give bundled agents concrete `note` / `ask` / snooze lifecycle rules so blocked idle chats do not appear Done. +- Keep agent-authored Work status lines glanceable by normalizing them to six words while preserving full blocking questions in `ade chat ask`. ## [1.2.46] - 2026-07-30 diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 54e781909..b79fb9785 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -418,7 +418,7 @@ ade chat create --lane lane-id --provider codex --no-parent # spawned chats de ade chat read session-id --limit 20 --text ade chat message session-id --kind auto --text "status/context" ade chat steer session-id --text "active-turn context" -ade chat note "running e2e shard 2/4" # update the caller's Work sidebar status; add --session to target explicitly +ade chat note "testing desktop auth fallback" # update the caller's Work status (max 6 words); add --session to target explicitly ade chat ask "Which account should I use?" # escalate a blocking question; add --session to target explicitly ade session show session-id --text # settle/snooze state, and why a snoozed row came back ade session snooze session-id --for 1h # 30m|1h|4h|1d|1.5h; a bare number means minutes; relative durations cap at 30d diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 333683bcb..fd0ddff28 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1718,7 +1718,7 @@ const HELP_BY_COMMAND: Record = { $ ade chat create --lane --provider claude --model anthropic/claude-opus-5 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active - $ ade chat note "running e2e shard 2/4" Update this session's Work sidebar status line + $ ade chat note "testing desktop auth fallback" Update the Work status line (max 6 words) $ ade chat ask "Which account should I use?" Escalate a blocking question to the user 'note' and 'ask' default to the caller and accept --session . 'chat settle' / 'chat unsettle' were removed: only the user (or a diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts index 60b17ad78..cc4e48a21 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -106,7 +106,11 @@ function seedDatabase(): void { set settled_at = ?, status_note = ? where id = ? `, - ).run("2026-01-02T00:01:00Z", "Index complete", "chat-run"); + ).run( + "2026-01-02T00:01:00Z", + "Indexing complete and waiting for final review now", + "chat-run", + ); db.prepare( ` update terminal_sessions @@ -249,7 +253,7 @@ describe("buildRosterSnapshot", () => { expect(byId.get("chat-run")).toMatchObject({ settledAt: "2026-01-02T00:01:00Z", - statusNote: "Index complete", + statusNote: "Indexing complete and waiting for final…", exitCode: null, }); expect(byId.get("chat-await")).toMatchObject({ diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index f5d2a40d8..27a364e69 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import { normalizeSessionStatusNote } from "../../../../desktop/src/shared/sessionStatusNote"; import type { SyncRosterChat, SyncRosterChatStatus, @@ -462,7 +463,7 @@ async function buildRosterProject( lastActivityAt, preview: truncatePreview(row.last_output_preview), settledAt: row.settled_at, - statusNote: row.status_note, + statusNote: normalizeSessionStatusNote(row.status_note), attentionRequestedAt: row.attention_requested_at, attentionMessage: row.attention_message, lastTurnFailedAt: row.last_turn_failed_at, diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index b90de869a..f9d5939df 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -602,7 +602,7 @@ _ ____ _____ $ ade chat create --lane --provider claude --model anthropic/claude-opus-5 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active - $ ade chat note "running e2e shard 2/4" Update this session's Work sidebar status line + $ ade chat note "testing desktop auth fallback" Update the Work status line (max 6 words) $ ade chat ask "Which account should I use?" Escalate a blocking question to the user 'note' and 'ask' default to the caller and accept --session . 'chat settle' / 'chat unsettle' were removed: only the user (or a diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index dbf7254de..3544a1cc1 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -191,8 +191,9 @@ row. If you are blocked, `ade chat ask ""` raises the row's hand. Treat the status line and hand-raise as separate signals: - **`ade chat note` explains the current state.** Write one concrete, - present-tense sentence containing the work or result and the next dependency. - Good: `CI is green; waiting for Codex review on commit 8f21a4c.` + present-tense summary of **3–6 words**. ADE truncates longer notes after the + sixth word, so put the decisive state first and never write a full sentence. + Good: `CI green; awaiting Codex review` Bad: `Working`, `Still looking`, `Blocked`, or `Done`. - **`ade chat ask` means work cannot continue without a user answer.** Ask the exact question that unlocks the next action. Include the meaningful choices @@ -215,12 +216,12 @@ Treat the status line and hand-raise as separate signals: | Situation | Required action | Example | |---|---|---| -| Actively working | `note` when the phase materially changes | `Reproduced the branch-refresh bug; updating the shared lane projection.` | -| Waiting on external work | `note`, then poll or snooze | `PR #977 CI is running on 8 shards; next poll is scheduled in 12 minutes.` | -| Blocked on user input | `note`, then `ask` | Note: `Two migration strategies preserve existing data; implementation is paused.` Ask: `Use the reversible in-place migration, or create a new store and copy records?` | -| Recoverable error | `note`, investigate, continue | `Desktop shard 7 timed out without a failed assertion; rerunning that shard.` | -| Unrecoverable error needing user action | `note`, then `ask` | Note: `GitHub rejected the push because no writable credential is available.` Ask: `Authenticate gh, or should I use the stored PAT?` | -| Delivered | final response plus `note` | `PR #977 merged; automatic lane naming and live branch refresh are shipped.` | +| Actively working | `note` when the phase materially changes | `Fixing live branch refresh` | +| Waiting on external work | `note`, then poll or snooze | `PR #977 CI running` | +| Blocked on user input | `note`, then `ask` | Note: `Waiting for migration choice` Ask: `Use the reversible in-place migration, or create a new store and copy records?` | +| Recoverable error | `note`, investigate, continue | `Rerunning timed-out desktop shard` | +| Unrecoverable error needing user action | `note`, then `ask` | Note: `Writable GitHub credential missing` Ask: `Authenticate gh, or should I use the stored PAT?` | +| Delivered | final response plus `note` | `PR #977 merged; fixes shipped` | Before ending any non-delivered turn, ask: **Can useful work continue without the user?** If yes, continue or snooze—do not hand-raise. If no, ensure both a diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 44d65772c..5dad70109 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -117,7 +117,7 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("avoid timezone arithmetic"); expect(result).toContain("brain machine's local timezone"); expect(result).toContain("verify it before ending the turn"); - expect(result).toContain('ade chat note "running e2e shard 2/4"'); + expect(result).toContain('ade chat note "testing desktop auth fallback"'); expect(result).toContain('ade chat ask ""'); // Agents cannot settle; the prompt says so instead of teaching a command. expect(result).toContain("You cannot settle or unsettle a session"); diff --git a/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts b/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts index d56e2d415..66e216195 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts @@ -54,7 +54,7 @@ describe("buildCursorSdkSystemPrompt", () => { it("always emits the shared session status protocol", () => { const out = buildCursorSdkSystemPrompt({ runtime: "local" }); - expect(out.text).toContain('ade chat note "running e2e shard 2/4"'); + expect(out.text).toContain('ade chat note "testing desktop auth fallback"'); expect(out.text).toContain('ade chat ask ""'); // Settlement is user- and PR-merge-driven only; the prompt must never hand // a Cursor agent a settle command again. diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index ddb3dbd58..ef6972bef 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -1364,11 +1364,34 @@ describe("sessionService resume metadata", () => { }); service.setStatusNote("session-markers", ` ${"n".repeat(210)} `); - expect(service.get("session-markers")?.statusNote).toBe("n".repeat(200)); + expect(service.get("session-markers")?.statusNote).toBe(`${"n".repeat(71)}…`); + expect(db.get<{ statusNote: string }>( + "select status_note as statusNote from terminal_sessions where id = ?", + ["session-markers"], + )?.statusNote).toBe(`${"n".repeat(71)}…`); + service.setStatusNote( + "session-markers", + " CI is green and waiting for Codex review now ", + ); + expect(service.get("session-markers")?.statusNote) + .toBe("CI is green and waiting for…"); + expect(db.get<{ statusNote: string }>( + "select status_note as statusNote from terminal_sessions where id = ?", + ["session-markers"], + )?.statusNote).toBe("CI is green and waiting for…"); + service.setStatusNote("session-markers", "界".repeat(100)); + expect(service.get("session-markers")?.statusNote).toBe(`${"界".repeat(71)}…`); service.setStatusNote("session-markers", " "); expect(service.get("session-markers")?.statusNote).toBeNull(); - service.settleSession("session-markers", { settledAt: "2026-03-17T01:00:00.000Z" }); + service.settleSession("session-markers", { + settledAt: "2026-03-17T01:00:00.000Z", + outcome: "Completed fixes and waiting for release review now", + }); + expect(db.get<{ statusNote: string }>( + "select status_note as statusNote from terminal_sessions where id = ?", + ["session-markers"], + )?.statusNote).toBe("Completed fixes and waiting for release…"); service.requestAttention("session-markers", ` ${"a".repeat(510)} `); expect(service.get("session-markers")).toEqual(expect.objectContaining({ settledAt: null, diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index e43bfc6f3..a8c7f971d 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -17,6 +17,7 @@ import type { ListSessionsArgs, UpdateSessionMetaArgs, } from "../../../shared/types"; +import { normalizeSessionStatusNote } from "../../../shared/sessionStatusNote"; import { isTrackedAgentCliToolType, parseSessionSettleOverride, @@ -545,7 +546,7 @@ export function createSessionService({ db }: { db: AdeDb }) { resumeCommand: deriveResumeMetadataCommand(resumeMetadata, row.resumeCommand, toolType), archivedAt: row.archivedAt ?? null, settledAt: normalizeIsoTimestamp(row.settledAt), - statusNote: normalizeOptionalText(row.statusNote, 200), + statusNote: normalizeSessionStatusNote(row.statusNote), attentionRequestedAt: normalizeIsoTimestamp(row.attentionRequestedAt), attentionMessage: normalizeOptionalText(row.attentionMessage, 500), attentionSource: normalizeAttentionSource(row.attentionSource), @@ -698,7 +699,7 @@ export function createSessionService({ db }: { db: AdeDb }) { [ normalizeIsoTimestamp(options.settledAt) ?? new Date().toISOString(), options.source ?? "user", - ...(hasOutcome ? [normalizeOptionalText(options.outcome, 200)] : []), + ...(hasOutcome ? [normalizeSessionStatusNote(options.outcome)] : []), ...newlySettled, ], ); @@ -1410,7 +1411,7 @@ export function createSessionService({ db }: { db: AdeDb }) { opts: { outcome?: string | null; settledAt?: string; source?: SessionSettleSource } = {}, ): boolean { const settledAt = normalizeIsoTimestamp(opts.settledAt) ?? new Date().toISOString(); - const outcome = normalizeOptionalText(opts.outcome, 200); + const outcome = normalizeSessionStatusNote(opts.outcome); return mutateSessionMeta(sessionId, (id) => { // An explicit settle also drops a stale keep-active pin — otherwise the // override would silently veto the settle the user just asked for. @@ -1674,7 +1675,7 @@ export function createSessionService({ db }: { db: AdeDb }) { return mutateSessionMeta(sessionId, (id) => { db.run( "update terminal_sessions set status_note = ? where id = ?", - [normalizeOptionalText(note, 200), id], + [normalizeSessionStatusNote(note), id], ); }); }, diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index e80b5aa18..92c7c16de 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -35,7 +35,8 @@ describe("ADE bootstrap guidance", () => { expect(bootstrap).toContain("ade skill show --text"); expect(bootstrap).toContain("ade chat scheduled-work create"); expect(bootstrap).toContain("tracked provider CLIs"); - expect(bootstrap).toContain('ade chat note "running e2e shard 2/4"'); + expect(bootstrap).toContain('ade chat note "testing desktop auth fallback"'); + expect(bootstrap).toContain("use 3–6 words"); expect(bootstrap).toContain('ade chat ask ""'); expect(bootstrap).toContain("a note alone can leave an idle row looking Done"); expect(bootstrap).toContain("The next accepted user message clears the prior hand-raise"); @@ -73,6 +74,7 @@ describe("ADE bootstrap guidance", () => { for (const invariant of [ "ade chat note", "ade chat ask", + "3–6 words", "next accepted user message clears the prior hand-raise", "You cannot settle or unsettle a session", ]) { diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 9a6bc0c4e..6ad003afb 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -25,7 +25,7 @@ export const adeBundledAgentSkills = [ */ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "ADE control protocol for truthful Work status:", - '- Working: `ade chat note "running e2e shard 2/4"`; state the concrete work and next dependency, never just "Working" or "Blocked".', + '- Working: `ade chat note "testing desktop auth fallback"`; use 3–6 words and be concrete. Long notes truncate; no sentences or vague "Working" / "Blocked".', '- Blocked on user input: first `ade chat note ""`, then `ade chat ask ""`; a note alone can leave an idle row looking Done.', "- The next accepted user message clears the prior hand-raise. If it does not resolve the blocker, update the note and call `ade chat ask` again before ending the turn.", '- Done: say so in your final message and leave a durable one-line result via `ade chat note ""`.', diff --git a/apps/desktop/src/shared/sessionStatusNote.ts b/apps/desktop/src/shared/sessionStatusNote.ts new file mode 100644 index 000000000..e13c3da0a --- /dev/null +++ b/apps/desktop/src/shared/sessionStatusNote.ts @@ -0,0 +1,20 @@ +const MAX_STATUS_NOTE_WORDS = 6; +const MAX_STATUS_NOTE_CHARACTERS = 72; +const MAX_STATUS_NOTE_INPUT_CHARACTERS = 200; + +export function normalizeSessionStatusNote(value: unknown): string | null { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) return null; + + const boundedInput = Array.from(raw) + .slice(0, MAX_STATUS_NOTE_INPUT_CHARACTERS) + .join(""); + const words = boundedInput.split(/\s+/); + const wordSummary = words.slice(0, MAX_STATUS_NOTE_WORDS).join(" "); + const characters = Array.from(wordSummary); + + if (characters.length > MAX_STATUS_NOTE_CHARACTERS) { + return `${characters.slice(0, MAX_STATUS_NOTE_CHARACTERS - 1).join("")}…`; + } + return words.length > MAX_STATUS_NOTE_WORDS ? `${wordSummary}…` : wordSummary; +} diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 5991106a0..b758a4bde 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -198,8 +198,9 @@ export type TerminalSessionSummary = { * attention_requested_at / attention_message / last_turn_failed_at). All * optional for migration tolerance; nullable-ISO semantics match * lastActivityAt. settledAt presence = the settled tier (activity clears it - * at the write site). statusNote is the agent-authored ~60-char status line - * (outcome line once settled). attentionRequestedAt/-Message carry an + * at the write site). statusNote is the agent-authored glanceable status line, + * normalized to at most six words (and used as the outcome once settled). + * attentionRequestedAt/-Message carry an * `ade chat ask` escalation for chat sessions. lastTurnFailedAt marks a chat * turn that died on a runtime/API error (cleared on next turn start). */ diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 5ce6e4c73..43f855341 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -66,7 +66,8 @@ next-run values before ending the turn. Regular chat and tracked CLI agents can also narrate their lifecycle directly into the Work list: -- `ade chat note "running e2e shard 2/4"` updates the row's quiet status line; +- `ade chat note "testing desktop auth fallback"` updates the row's quiet + status line, normalized to at most six words; an empty note clears it. - `ade chat ask "Which account should I use?"` creates a loud, persisted `Needs you` state, clears settle, and sends a time-sensitive push. The next diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 86f1bc689..62fa3d43f 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -1276,7 +1276,9 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. not dismiss the user's pending question. Provider structured input carries its own pending item id. OSC markers and prompt-looking output never create `Needs you`. `ade chat note ""` clears - only the status line. + only the status line. Status notes are normalized to at most six words at + the session-service boundary so the sidebar remains glanceable; blocking + detail belongs in the separate `ade chat ask` question. Beyond the binary settle there is a tri-state **settle override** (`terminal_sessions.settle_override`). `"settled"` behaves like a declared From 3d1e54c301af53bfeb332b69c1898fdd62f140e0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:39:33 -0400 Subject: [PATCH 6/9] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20fix=20d?= =?UTF-8?q?esktop=20CI=20and=20Codex=20P2s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/chat/agentChatService.test.ts | 185 +++++++++++++++++- .../main/services/chat/agentChatService.ts | 50 ++++- .../services/github/githubService.test.ts | 8 +- .../src/main/services/github/githubService.ts | 30 ++- apps/desktop/src/shared/adeCliGuidance.ts | 24 +-- 5 files changed, 259 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7f02e6cd2..bfd35a941 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -3123,10 +3123,10 @@ describe("createAgentChatService", () => { }); const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; - expect(opts?.systemPrompt?.append).toContain("control plane for ADE state"); + expect(opts?.systemPrompt?.append).toContain("CLI controls ADE state"); expect(opts?.systemPrompt?.append).toContain("read the matching `ade-*` skill"); expect(opts?.systemPrompt?.append).toContain("ade help "); - expect(opts?.systemPrompt?.append).toContain("clean up processes you start"); + expect(opts?.systemPrompt?.append).toContain("clean up started processes"); expect(opts?.systemPrompt?.append).toContain( `This ADE chat session is \`${session.id}\`. Pass \`--session ${session.id}\` to its status commands.`, ); @@ -3295,10 +3295,10 @@ describe("createAgentChatService", () => { .find((payload) => payload.includes("Inspect the repo and report the chat wiring.")); expect(userTurnPayload).toContain("[ADE launch directive]"); - expect(userTurnPayload).not.toContain("control plane for ADE state"); + expect(userTurnPayload).not.toContain("CLI controls ADE state"); expect(userTurnPayload).not.toContain("ade actions list --text"); const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; - expect(opts?.systemPrompt?.append).toContain("control plane for ADE state"); + expect(opts?.systemPrompt?.append).toContain("CLI controls ADE state"); }); it("keeps Claude SDK setting sources and skills enabled without output-style plugins", async () => { @@ -6886,13 +6886,13 @@ describe("createAgentChatService", () => { expect(firstUserContent).toContain(tmpRoot); expect(firstUserContent).toContain("Read-only inspection outside that worktree is allowed"); expect(firstUserContent).toContain("mutating commands only inside that worktree"); - expect(firstUserContent).toContain("control plane for ADE state"); + expect(firstUserContent).toContain("CLI controls ADE state"); expect(firstUserContent).toContain("ade actions list --text"); expect(firstUserContent).toContain( `This ADE chat session is \`${session.id}\`. Pass \`--session ${session.id}\` to its status commands.`, ); expect(secondUserContent).not.toContain("[ADE launch directive]"); - expect(secondUserContent).toContain("control plane for ADE state"); + expect(secondUserContent).toContain("CLI controls ADE state"); }); it("starts Codex sessions without ADE-owned tool server injection", async () => { @@ -6951,7 +6951,7 @@ describe("createAgentChatService", () => { } | undefined; const textInput = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; expect(turnParams?.collaborationMode?.settings?.developer_instructions).toBe("system prompt"); - expect(textInput).not.toContain("control plane for ADE state"); + expect(textInput).not.toContain("CLI controls ADE state"); expect(textInput).not.toContain("ade actions list --text"); expect(textInput).toContain("Inspect the repo and fix the lane launch bug."); }); @@ -11093,6 +11093,177 @@ describe("createAgentChatService", () => { expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledTimes(1); }); + it.each([ + ["opencode", "", "opencode/anthropic/claude-sonnet-5"], + ["cursor", "composer-2", "cursor/composer-2"], + ["droid", "custom:claude-sonnet-5-thinking-32000", "droid/custom:claude-sonnet-5-thinking-32000"], + ] as const)( + "clears lifecycle markers when an idle %s user steer is dispatched", + async (provider, model, modelId) => { + let finishTurn = () => {}; + const turnGate = new Promise((resolve) => { + finishTurn = resolve; + }); + if (provider === "opencode") { + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", textDelta: "working" }; + await turnGate; + yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } }; + })(), + } as any); + } else if (provider === "cursor") { + process.env.CURSOR_API_KEY = "cursor-test-key"; + mockState.cursorSendPromptGate = turnGate; + } else { + mockState.droidPromptGate = turnGate; + } + + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider, + model, + modelId, + }); + sessionService.clearTurnStartMarkers.mockClear(); + + let turnSettled = false; + const steerPromise = service.steerUserMessage({ + sessionId: session.id, + text: "Continue from my answer.", + }).finally(() => { + turnSettled = true; + }); + + try { + if (provider === "cursor") { + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThan(0); + }); + mockState.cursorSdkPooled.bridge.onRunStarted({ + agentId: "cursor-sdk-agent-1", + runId: "cursor-sdk-run-1", + modelSdkId: "composer-2", + }, { runtime: "local" }); + } else if (provider === "droid") { + await vi.waitFor(() => { + expect(mockState.droidPromptCalls.length).toBeGreaterThan(0); + }); + mockState.droidPooled.bridge.onEvent({ + type: "assistant", + message: { content: [] }, + }); + } + await vi.waitFor(() => { + expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledWith(session.id); + }); + expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledTimes(1); + expect(turnSettled).toBe(false); + } finally { + finishTurn(); + } + await expect(steerPromise).resolves.toMatchObject({ queued: false }); + }, + ); + + it("preserves lifecycle markers when an idle Cursor user steer is rejected before dispatch", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + vi.mocked(acquireCursorSdkConnection).mockRejectedValueOnce( + new Error("Cursor rejected the dispatch."), + ); + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + sessionService.clearTurnStartMarkers.mockClear(); + + await expect(service.steerUserMessage({ + sessionId: session.id, + text: "Continue from my answer.", + })).rejects.toThrow("Cursor rejected the dispatch."); + expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + }); + + it("preserves lifecycle markers when an idle OpenCode prompt is rejected before dispatch", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/anthropic/claude-sonnet-5", + }); + vi.mocked(streamText).mockReturnValueOnce({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } }; + })(), + } as any); + await service.runSessionTurn({ + sessionId: session.id, + text: "Start the reusable OpenCode runtime.", + }); + const handle = await vi.mocked(startOpenCodeSession).mock.results.at(-1)!.value as { + client: { + session: { + promptAsync: ReturnType; + }; + }; + }; + handle.client.session.promptAsync.mockRejectedValueOnce( + new Error("OpenCode rejected the prompt."), + ); + sessionService.clearTurnStartMarkers.mockClear(); + + await expect(service.steerUserMessage({ + sessionId: session.id, + text: "Continue from my answer.", + })).resolves.toMatchObject({ queued: false }); + expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + }); + + it("preserves lifecycle markers when a Cursor user steer is rejected by a full queue", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + let finishTurn = () => {}; + mockState.cursorSendPromptGate = new Promise((resolve) => { + finishTurn = resolve; + }); + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await service.sendMessage({ + sessionId: session.id, + text: "Keep this turn active.", + }, { awaitDispatch: true }); + for (let index = 0; index < 10; index += 1) { + await expect(service.steer({ + sessionId: session.id, + text: `Queued agent message ${index + 1}.`, + })).resolves.toMatchObject({ queued: true }); + } + sessionService.clearTurnStartMarkers.mockClear(); + + try { + await expect(service.steerUserMessage({ + sessionId: session.id, + text: "This message should be rejected.", + })).resolves.toMatchObject({ + queued: false, + reason: "queue_full", + }); + expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + } finally { + await service.interrupt({ sessionId: session.id }); + finishTurn(); + } + }); + it("settles a still-open background task as stopped on interrupt (genuine teardown)", async () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 753738bb7..e52a76ddc 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -21066,6 +21066,7 @@ export function createAgentChatService(args: { providerSlashCommand?: boolean; forceClaudeUserMessage?: boolean; onDispatched?: () => void; + onBackendDispatched?: () => void; }, ): Promise => { const runtimeKind = managed.runtime?.kind; @@ -21234,8 +21235,8 @@ export function createAgentChatService(args: { }); await promptAccepted; - if (args.onDispatched) { - args.onDispatched(); + if (args.onBackendDispatched) { + args.onBackendDispatched(); } let stepNumber = 0; @@ -34505,7 +34506,8 @@ export function createAgentChatService(args: { metadata, laneDirectiveKey, providerSlashCommand, - onDispatched: onBackendDispatched ?? onDispatched, + onDispatched, + onBackendDispatched, }); return; } @@ -35134,7 +35136,10 @@ export function createAgentChatService(args: { const steerWithOptions = async ( { sessionId, text, displayText, attachments = [], contextAttachments = [], metadata, reasoningEffort, executionMode, interactionMode, dispatchMode }: AgentChatSteerArgs, - options?: { allowPendingInput?: boolean }, + options?: { + allowPendingInput?: boolean; + onAcceptedDispatch?: () => void; + }, ): Promise => { if (dispatchMode !== undefined && dispatchMode !== "inline" && dispatchMode !== "interrupt") { throw new Error(`Unsupported Claude steer dispatch mode: ${String(dispatchMode)}`); @@ -35198,6 +35203,7 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } + preparedSteer.onBackendDispatched = options?.onAcceptedDispatch; await executePreparedSendMessage(preparedSteer); return { steerId, queued: false }; } @@ -35273,6 +35279,7 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } + preparedSteer.onBackendDispatched = options?.onAcceptedDispatch; await executePreparedSendMessage(preparedSteer); return { steerId, queued: false }; } @@ -35347,6 +35354,7 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } + preparedSteer.onBackendDispatched = options?.onAcceptedDispatch; await executePreparedSendMessage(preparedSteer); return { steerId, queued: false }; } @@ -35466,6 +35474,9 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } + if (managed.session.provider === "opencode") { + preparedSteer.onBackendDispatched = options?.onAcceptedDispatch; + } if (managed.session.provider === "claude") { const runtime = ensureClaudeSessionRuntime(managed); if (runtime.busy || managed.session.status === "active") { @@ -35529,12 +35540,39 @@ export function createAgentChatService(args: { const steerUserMessage = async ( args: AgentChatSteerArgs, ): Promise => { + const managed = ensureManagedSession(args.sessionId); const routableMessage = args.text.trim().length > 0 || (args.attachments?.length ?? 0) > 0 || (args.contextAttachments?.length ?? 0) > 0; - const result = await steerWithOptions(args); - if (routableMessage && result.reason !== "queue_full" && !args.metadata?.scheduledWake) { + const waitsForProviderDispatch = + ( + managed.session.provider === "opencode" + || managed.session.provider === "cursor" + || managed.session.provider === "droid" + ) + && !canRouteActiveSendToSteer(managed); + let markersCleared = false; + const clearAcceptedUserMarkers = (): void => { + if ( + markersCleared + || !routableMessage + || args.metadata?.scheduledWake + ) { + return; + } + markersCleared = true; sessionService.clearTurnStartMarkers(args.sessionId); + }; + const result = await steerWithOptions(args, { + onAcceptedDispatch: clearAcceptedUserMarkers, + }); + if ( + routableMessage + && result.reason !== "queue_full" + && !args.metadata?.scheduledWake + && (!waitsForProviderDispatch || markersCleared) + ) { + clearAcceptedUserMarkers(); } return result; }; diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 339df3689..60ccb34ea 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -836,22 +836,24 @@ describe("githubService.getStatus", () => { jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), ); - const status = await makeService({ + const service = makeService({ credentialStore, ghAuthTokenProvider: () => ({ token: "gho_cli_token", ghCliPath: "/opt/homebrew/bin/gh", ghAuthError: null, }), - }).getStatus(); + }); + const status = await service.getStatus(); expect(status).toMatchObject({ authSource: "gh", connected: true, - patTokenStored: false, + patTokenStored: true, repoAccessOk: null, userLogin: "alice", }); + expect(service.getTokenOrThrow()).toBe("gho_cli_token"); expect(mockFetch).toHaveBeenCalledTimes(1); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; expect((init.headers as Record).authorization) diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index c55945642..caeb7abc9 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -713,34 +713,44 @@ export function createGithubService({ }; const readAuthToken = async (): Promise => { + const patLookup = readPatAuthToken(); + const patTokenStored = Boolean(patLookup); let ghFallback: GitHubTokenLookup = { token: null, source: "none", - patTokenStored: false, + patTokenStored, ghCliPath: null, ghAuthError: null, }; return await selectGithubOperationCredentialAsync({ - environment: () => readEnvironmentAuthToken(), + environment: () => { + const environment = readEnvironmentAuthToken(); + return environment ? { ...environment, patTokenStored } : null; + }, gh: async () => { const gh = await readGhAuthToken(); - ghFallback = { ...gh, source: "none", patTokenStored: false }; - return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; + ghFallback = { ...gh, source: "none", patTokenStored }; + return gh.token ? { ...gh, source: "gh", patTokenStored } : null; }, - pat: () => readPatAuthToken(), + pat: () => patLookup, }) ?? ghFallback; }; const readAuthTokenSync = (): GitHubTokenLookup => { + const patLookup = readPatAuthToken(); + const patTokenStored = Boolean(patLookup); let ghFallback: GitHubTokenLookup = { token: null, source: "none", - patTokenStored: false, + patTokenStored, ghCliPath: null, ghAuthError: null, }; return selectGithubOperationCredential({ - environment: () => readEnvironmentAuthToken(), + environment: () => { + const environment = readEnvironmentAuthToken(); + return environment ? { ...environment, patTokenStored } : null; + }, gh: () => { if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { sharedGhAuth.authCache = null; @@ -755,10 +765,10 @@ export function createGithubService({ ghCliPath: null, ghAuthError: hostsToken ? null : "GitHub auth has not been resolved yet.", }; - ghFallback = { ...gh, source: "none", patTokenStored: false }; - return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; + ghFallback = { ...gh, source: "none", patTokenStored }; + return gh.token ? { ...gh, source: "gh", patTokenStored } : null; }, - pat: () => readPatAuthToken(), + pat: () => patLookup, }) ?? ghFallback; }; diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 6ad003afb..37fd567dc 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -25,12 +25,12 @@ export const adeBundledAgentSkills = [ */ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "ADE control protocol for truthful Work status:", - '- Working: `ade chat note "testing desktop auth fallback"`; use 3–6 words and be concrete. Long notes truncate; no sentences or vague "Working" / "Blocked".', - '- Blocked on user input: first `ade chat note ""`, then `ade chat ask ""`; a note alone can leave an idle row looking Done.', - "- The next accepted user message clears the prior hand-raise. If it does not resolve the blocker, update the note and call `ade chat ask` again before ending the turn.", - '- Done: say so in your final message and leave a durable one-line result via `ade chat note ""`.', - "- You cannot settle or unsettle a session; `ade chat settle` / `ade session settle` no longer exist. Filing a row as done is the user's call, or the automatic result of its PR merging.", - "- Waiting on something you expect to take a while? `ade session snooze --for ` quiets the row without claiming the work is done, and a hand-raise wakes it early.", + '- Working: `ade chat note "testing desktop auth fallback"`; use 3–6 words, concretely. Longer notes truncate.', + '- Blocked on input: call `ade chat note ""`, then `ade chat ask ""`; a note alone can leave an idle row looking Done.', + "- The next accepted user message clears the prior hand-raise. Re-note and re-ask before ending if still blocked.", + '- Done: report it and leave `ade chat note ""`.', + "- You cannot settle or unsettle a session; that is the user's call, or the automatic result of its PR merging.", + "- Waiting a while? `ade session snooze --for ` hides the row without claiming done; a hand-raise wakes it.", ].join("\n"); /** @@ -63,14 +63,14 @@ export function buildAdeBootstrapGuidance( ): string { return [ "## ADE", - "You're working inside ADE, a local-first dev environment (lanes, chats, terminals, PRs, proof/artifacts, app & iOS-simulator & browser control). The `ade` CLI is your control plane for ADE state — it is not in your training data, so consult `ade help ` rather than guessing command syntax.", - "Your ADE capabilities ship as Agent Skills. When a task touches an ADE area (lanes/git, PRs, proof & screenshots, the built-in browser, iOS simulator, app control, Linear, deeplinks, or searching across everything in ADE), read the matching `ade-*` skill before acting; otherwise ignore them.", + "ADE is a local-first dev environment for lanes, chats, terminals, PRs, proof, apps, iOS, and browsers. Its `ade` CLI controls ADE state; use `ade help ` instead of guessing.", + "ADE capabilities ship as Agent Skills. For ADE tasks, read the matching `ade-*` skill before acting.", `Skills: ${adeBundledAgentSkills.map((name) => `\`${name}\``).join(", ")}.`, formatAdeAgentSkillRootsForPrompt(skillRoots), - "If your runtime does not expose those skills natively, use `ade skill list --text` to discover them and `ade skill show --text` to load one on demand.", - "If the direct `mcp__computer_use` tools are present, use them for Codex Computer Use and honor their per-app approvals; do not initialize `@oai/sky` through `node_repl` as a substitute.", - "Ground truth for any `ade` invocation is `ade help ` and `ade actions list --text`; prefer typed commands with `--text`. Project secrets are available through `ade secrets`; read only the named secret the user asks you to use and avoid printing secret values. Track and clean up processes you start.", - "`ade chat scheduled-work create` schedules durable self-resume for bound chats and tracked provider CLIs.", + "If skills are not native, discover with `ade skill list --text` and load with `ade skill show --text`.", + "For Codex Computer Use, prefer direct `mcp__computer_use` tools and honor per-app approvals; never substitute `@oai/sky` via `node_repl`.", + "CLI ground truth: `ade help ` and `ade actions list --text`; prefer typed commands with `--text`. Read only requested `ade secrets`, never print them, and clean up started processes.", + "`ade chat scheduled-work create` durably resumes bound chats and tracked provider CLIs.", ADE_SESSION_STATUS_PROTOCOL_GUIDANCE, ].join("\n"); } From 5b57ef782324c93ad6c01f5b05de26298f8fe1e5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:27:29 -0400 Subject: [PATCH 7/9] ship: fix CI and review findings --- CHANGELOG.md | 2 +- apps/ade-cli/README.md | 2 +- apps/ade-cli/src/adeRpcServer.test.ts | 10 +-- apps/ade-cli/src/cli.ts | 2 +- .../src/headlessLinearServices.test.ts | 81 +++++++++++++++++- apps/ade-cli/src/headlessLinearServices.ts | 46 +++++----- apps/desktop/resources/ade-cli-help.txt | 2 +- .../ade-cli-control-plane/SKILL.md | 5 +- .../services/ai/tools/systemPrompt.test.ts | 2 +- .../src/main/services/ipc/registerIpc.ts | 2 +- .../main/services/ipc/runtimeBridge.test.ts | 85 +++++++++++++++++++ .../components/chat/AgentChatPane.test.tsx | 51 +++++++++++ .../components/chat/AgentChatPane.tsx | 1 + .../desktop/src/shared/adeCliGuidance.test.ts | 2 + apps/desktop/src/shared/adeCliGuidance.ts | 2 +- .../src/shared/sessionStatusNote.test.ts | 21 +++++ apps/desktop/src/shared/sessionStatusNote.ts | 7 +- apps/desktop/src/shared/types/sessions.ts | 3 +- docs/features/agents/README.md | 2 +- .../features/terminals-and-sessions/README.md | 6 +- 20 files changed, 290 insertions(+), 44 deletions(-) create mode 100644 apps/desktop/src/shared/sessionStatusNote.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5be933c..d9c390ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show an animated `Naming lane…` placeholder while automatic lane identity is still resolving, reveal the deterministic fallback only on failure, refresh renamed branches live across Lanes, Git Actions, and session hover details, and keep sidebar Working durations anchored to each chat turn. - Prefer explicit environment and GitHub CLI credentials before a stored PAT, keep the GitHub App read-only and dedicated to real-time PR updates, avoid false classic OAuth scope errors for App authorization, and render App rate limits as a concise cooldown instead of a raw relay error. - Clear an explicit `Needs you` hand-raise when the user replies during an active turn, keep agent-to-agent steers from dismissing it, and give bundled agents concrete `note` / `ask` / snooze lifecycle rules so blocked idle chats do not appear Done. -- Keep agent-authored Work status lines glanceable by normalizing them to six words while preserving full blocking questions in `ade chat ask`. +- Keep agent-authored Work status lines glanceable by normalizing them to 3–6 words and at most 72 characters while preserving full blocking questions in `ade chat ask`. ## [1.2.46] - 2026-07-30 diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index b79fb9785..0516adf6c 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -418,7 +418,7 @@ ade chat create --lane lane-id --provider codex --no-parent # spawned chats de ade chat read session-id --limit 20 --text ade chat message session-id --kind auto --text "status/context" ade chat steer session-id --text "active-turn context" -ade chat note "testing desktop auth fallback" # update the caller's Work status (max 6 words); add --session to target explicitly +ade chat note "testing desktop auth fallback" # update Work status (3–6 words, max 72 characters); add --session to target explicitly ade chat ask "Which account should I use?" # escalate a blocking question; add --session to target explicitly ade session show session-id --text # settle/snooze state, and why a snoozed row came back ade session snooze session-id --for 1h # 30m|1h|4h|1d|1.5h; a bare number means minutes; relative durations cap at 30d diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 0b0d57cc1..106fc8f76 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2229,9 +2229,9 @@ describe("adeRpcServer", () => { // it ends with the user prompt and carries the inline guidance preamble. const createCall = (fixture.runtime.ptyService.create as ReturnType).mock.calls[0]?.[0] as { args: string[] }; const finalArg = createCall.args[createCall.args.length - 1]; - expect(finalArg).toContain("control plane for ADE state"); - expect(finalArg).toContain("proof & screenshots"); - expect(finalArg).toContain("clean up processes you start"); + expect(finalArg).toContain("CLI controls ADE state"); + expect(finalArg).toContain("PRs, proof, apps"); + expect(finalArg).toContain("clean up started processes"); expect(finalArg).toContain("ade chat note"); expect(finalArg).toContain("ade chat ask"); expect(finalArg).toContain("You cannot settle or unsettle a session"); @@ -2239,7 +2239,7 @@ describe("adeRpcServer", () => { expect(response.structuredContent.startupCommand).toContain("claude"); expect(response.structuredContent.startupCommand).toContain("--model"); expect(response.structuredContent.startupCommand).toContain("--permission-mode"); - expect(response.structuredContent.startupCommand).toContain("control plane for ADE state"); + expect(response.structuredContent.startupCommand).toContain("CLI controls ADE state"); expect(response.structuredContent.permissionMode).toBe("default"); expect(response.structuredContent.contextRef?.path).toBeNull(); }); @@ -2881,7 +2881,7 @@ describe("adeRpcServer", () => { expect(response.structuredContent.permissionMode).toBe("plan"); expect(response.structuredContent.startupCommand).toContain("--sandbox"); expect(response.structuredContent.startupCommand).toContain("read-only"); - expect(response.structuredContent.startupCommand).toContain("control plane for ADE state"); + expect(response.structuredContent.startupCommand).toContain("CLI controls ADE state"); const contextPath = response.structuredContent.contextRef?.path as string | null; expect(contextPath).toBeTruthy(); expect(contextPath?.includes("/.ade/cache/orchestrator/agent-context/run-123/")).toBe(true); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index fd0ddff28..8e3452876 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1718,7 +1718,7 @@ const HELP_BY_COMMAND: Record = { $ ade chat create --lane --provider claude --model anthropic/claude-opus-5 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active - $ ade chat note "testing desktop auth fallback" Update the Work status line (max 6 words) + $ ade chat note "testing desktop auth fallback" # Update the Work status line (3–6 words, max 72 characters) $ ade chat ask "Which account should I use?" Escalate a blocking question to the user 'note' and 'ask' default to the caller and accept --session . 'chat settle' / 'chat unsettle' were removed: only the user (or a diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 551328919..c01fd621d 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -646,6 +646,7 @@ describe("headlessLinearServices", () => { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "environment", connected: true, + patTokenStored: true, userLogin: "octocat", }); expect(fetchImpl).toHaveBeenCalledWith( @@ -671,8 +672,14 @@ describe("headlessLinearServices", () => { it("keeps the read-only GitHub App out of operational REST credential selection", async () => { const previousAdeHome = process.env.ADE_HOME; + const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; + const previousGitHubToken = process.env.GITHUB_TOKEN; + const previousGhToken = process.env.GH_TOKEN; const previousFetch = globalThis.fetch; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-")); + delete process.env.ADE_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; const machineCredentialStore = new EncryptedFileCredentialStore(); machineCredentialStore.setSync("github.token.v1", "ghp_stored_token"); machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ @@ -723,16 +730,28 @@ describe("headlessLinearServices", () => { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; else process.env.ADE_HOME = previousAdeHome; + if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; + else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; + if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousGitHubToken; + if (previousGhToken == null) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousGhToken; } }); it("keeps GitHub CLI auth ahead of GitHub App authorization for async REST calls", async () => { const previousAdeHome = process.env.ADE_HOME; + const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; + const previousGitHubToken = process.env.GITHUB_TOKEN; + const previousGhToken = process.env.GH_TOKEN; const previousGhConfigDir = process.env.GH_CONFIG_DIR; const previousDisableGhAuthFallback = process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const previousFetch = globalThis.fetch; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-")); process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-gh-config-")); + delete process.env.ADE_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; fs.writeFileSync( path.join(process.env.GH_CONFIG_DIR, "hosts.yml"), @@ -773,7 +792,7 @@ describe("headlessLinearServices", () => { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "gh", connected: true, - patTokenStored: false, + patTokenStored: true, userLogin: "octocat", }); expect(fetchImpl).toHaveBeenCalledWith( @@ -788,6 +807,12 @@ describe("headlessLinearServices", () => { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; else process.env.ADE_HOME = previousAdeHome; + if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; + else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; + if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousGitHubToken; + if (previousGhToken == null) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousGhToken; if (previousGhConfigDir == null) delete process.env.GH_CONFIG_DIR; else process.env.GH_CONFIG_DIR = previousGhConfigDir; if (previousDisableGhAuthFallback == null) delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; @@ -795,6 +820,60 @@ describe("headlessLinearServices", () => { } }); + it("preserves failed GitHub CLI diagnostics when a stored PAT is the fallback", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; + const previousGitHubToken = process.env.GITHUB_TOKEN; + const previousGhToken = process.env.GH_TOKEN; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-pat-fallback-")); + delete process.env.ADE_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + const machineCredentialStore = new EncryptedFileCredentialStore(); + machineCredentialStore.setSync("github.token.v1", "ghp_stored_token"); + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-oauth-scopes": "repo, workflow", + }, + })) as unknown as typeof fetch; + + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: null, + ghCliPath: "/usr/local/bin/gh", + ghAuthError: "not logged in to github.com", + }), + }, + ); + try { + expect(githubService.getTokenOrThrow()).toBe("ghp_stored_token"); + await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ + authSource: "pat", + connected: true, + patTokenStored: true, + ghCliPath: "/usr/local/bin/gh", + ghAuthError: "not logged in to github.com", + userLogin: "octocat", + }); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; + else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; + if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousGitHubToken; + if (previousGhToken == null) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousGhToken; + } + }); + it("does not share Linear credentials between headless projects", () => { const previousAdeHome = process.env.ADE_HOME; const previousAdeLinearApi = process.env.ADE_LINEAR_API; diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index fb11146bc..9f5236954 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -534,6 +534,10 @@ export function createHeadlessGitHubService( onStatusChanged?: (status: HeadlessGitHubStatus) => void; githubRelaySecretReader?: GitHubRelaySecretReader | null; getAccountAccessToken?: (() => Promise) | null; + ghAuthTokenProvider?: (() => Pick< + HeadlessGitHubTokenLookup, + "token" | "ghCliPath" | "ghAuthError" + >) | null; } = {}, ): HeadlessGitHubService { const credentialStore = new EncryptedFileCredentialStore(); @@ -575,10 +579,12 @@ export function createHeadlessGitHubService( }; const readToken = (): HeadlessGitHubTokenLookup => { + const patToken = readStoredPatToken(); + const patTokenStored = Boolean(patToken); let ghFallback: HeadlessGitHubTokenLookup = { token: null, source: "none", - patTokenStored: false, + patTokenStored, ghCliPath: null, ghAuthError: null, }; @@ -586,20 +592,17 @@ export function createHeadlessGitHubService( environment: () => { const token = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); return token - ? { token, source: "environment", patTokenStored: false, ghCliPath: null, ghAuthError: null } + ? { token, source: "environment", patTokenStored, ghCliPath: null, ghAuthError: null } : null; }, gh: () => { - const gh = ghAuthToken(); - ghFallback = { ...gh, source: "none", patTokenStored: false }; - return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; - }, - pat: () => { - const token = readStoredPatToken(); - return token - ? { token, source: "pat", patTokenStored: true, ghCliPath: null, ghAuthError: null } - : null; + const gh = options.ghAuthTokenProvider?.() ?? ghAuthToken(); + ghFallback = { ...gh, source: "none", patTokenStored }; + return gh.token ? { ...gh, source: "gh", patTokenStored } : null; }, + pat: () => patToken + ? { ...ghFallback, token: patToken, source: "pat", patTokenStored } + : null, }) ?? ghFallback; }; @@ -616,10 +619,12 @@ export function createHeadlessGitHubService( }; const readTokenAsync = async (): Promise => { + const patToken = await readStoredPatTokenAsync(); + const patTokenStored = Boolean(patToken); let ghFallback: HeadlessGitHubTokenLookup = { token: null, source: "none", - patTokenStored: false, + patTokenStored, ghCliPath: null, ghAuthError: null, }; @@ -627,20 +632,17 @@ export function createHeadlessGitHubService( environment: () => { const token = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); return token - ? { token, source: "environment", patTokenStored: false, ghCliPath: null, ghAuthError: null } + ? { token, source: "environment", patTokenStored, ghCliPath: null, ghAuthError: null } : null; }, gh: async () => { - const gh = await ghAuthTokenAsync(); - ghFallback = { ...gh, source: "none", patTokenStored: false }; - return gh.token ? { ...gh, source: "gh", patTokenStored: false } : null; - }, - pat: async () => { - const token = await readStoredPatTokenAsync(); - return token - ? { token, source: "pat", patTokenStored: true, ghCliPath: null, ghAuthError: null } - : null; + const gh = options.ghAuthTokenProvider?.() ?? await ghAuthTokenAsync(); + ghFallback = { ...gh, source: "none", patTokenStored }; + return gh.token ? { ...gh, source: "gh", patTokenStored } : null; }, + pat: () => patToken + ? { ...ghFallback, token: patToken, source: "pat", patTokenStored } + : null, }) ?? ghFallback; }; diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index f9d5939df..e959084c5 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -602,7 +602,7 @@ _ ____ _____ $ ade chat create --lane --provider claude --model anthropic/claude-opus-5 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active - $ ade chat note "testing desktop auth fallback" Update the Work status line (max 6 words) + $ ade chat note "testing desktop auth fallback" # Update the Work status line (3–6 words, max 72 characters) $ ade chat ask "Which account should I use?" Escalate a blocking question to the user 'note' and 'ask' default to the caller and accept --session . 'chat settle' / 'chat unsettle' were removed: only the user (or a diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 3544a1cc1..82dfe5176 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -191,8 +191,9 @@ row. If you are blocked, `ade chat ask ""` raises the row's hand. Treat the status line and hand-raise as separate signals: - **`ade chat note` explains the current state.** Write one concrete, - present-tense summary of **3–6 words**. ADE truncates longer notes after the - sixth word, so put the decisive state first and never write a full sentence. + present-tense summary of **3–6 words and at most 72 characters**. ADE + truncates longer notes, so put the decisive state first and never write a + full sentence. Good: `CI green; awaiting Codex review` Bad: `Working`, `Still looking`, `Blocked`, or `Done`. - **`ade chat ask` means work cannot continue without a user answer.** Ask the diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 5dad70109..b25754f55 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -331,7 +331,7 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("unless the user explicitly says stop, pause, or only report status"); expect(result).toContain("## ADE"); expect(result).toContain("read the matching `ade-*` skill"); - expect(result).toContain("Your ADE capabilities ship as Agent Skills"); + expect(result).toContain("ADE capabilities ship as Agent Skills"); expect(result).toContain("ade-ios-simulator"); expect(result).toContain("ade-cli-control-plane"); expect(result).toContain("ade-orchestrator"); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 6ffda6a52..bddc9b245 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -9898,7 +9898,7 @@ export function registerIpc({ const ctx = ensurePrAiResolutionContext(); const sessionDetail = ctx.sessionService.get(sessionId); if (sessionDetail?.status === "running") { - await ctx.agentChatService.steer({ sessionId, text }); + await ctx.agentChatService.steerUserMessage({ sessionId, text }); return; } await ctx.agentChatService.sendMessage({ sessionId, text }); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 540043b8d..49f3c583a 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -3044,4 +3044,89 @@ describe("registerIpc sync bridge", () => { expect(dispose).not.toHaveBeenCalled(); expect(deleteSession).not.toHaveBeenCalled(); }); + + it("routes running PR AI input through human steer semantics and keeps idle input on send", async () => { + vi.useFakeTimers(); + let sessionStatus: "running" | "idle" = "running"; + const sendMessage = vi.fn().mockResolvedValue(undefined); + const steer = vi.fn().mockResolvedValue(undefined); + const steerUserMessage = vi.fn().mockResolvedValue({ steerId: "steer-1", queued: true }); + const interrupt = vi.fn().mockResolvedValue(undefined); + const finalizeResolverSession = vi.fn().mockResolvedValue(undefined); + const sessionId = "pr-ai-session-1"; + + registerIpc({ + getCtx: () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + project: { rootPath: process.cwd() }, + agentChatService: { + createSession: vi.fn().mockResolvedValue({ id: sessionId }), + sendMessage, + steer, + steerUserMessage, + interrupt, + }, + conflictService: { + prepareResolverSession: vi.fn().mockResolvedValue({ + runId: "resolver-run-1", + status: "ready", + cwdLaneId: "lane-target", + integrationLaneId: null, + promptFilePath: path.resolve("package.json"), + contextGaps: [], + }), + attachResolverSession: vi.fn().mockResolvedValue(undefined), + finalizeResolverSession, + }, + sessionService: { + get: vi.fn(() => ({ + id: sessionId, + status: sessionStatus, + exitCode: null, + })), + }, + }) as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.prsAiResolutionStart)?.(eventForSender(), { + context: { + sourceTab: "normal", + sourceLaneId: "lane-source", + targetLaneId: "lane-target", + }, + model: "openai/gpt-5.4", + }), + ).resolves.toMatchObject({ sessionId, status: "started" }); + sendMessage.mockClear(); + + await ipcHandlers.get(IPC.prsAiResolutionInput)?.(eventForSender(), { + sessionId, + text: "Keep the user's attention lifecycle.", + }); + expect(steerUserMessage).toHaveBeenCalledWith({ + sessionId, + text: "Keep the user's attention lifecycle.", + }); + expect(steer).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + + sessionStatus = "idle"; + await ipcHandlers.get(IPC.prsAiResolutionInput)?.(eventForSender(), { + sessionId, + text: "Start the next resolver turn.", + }); + expect(sendMessage).toHaveBeenCalledWith({ + sessionId, + text: "Start the next resolver turn.", + }); + + await ipcHandlers.get(IPC.prsAiResolutionStop)?.(eventForSender(), { sessionId }); + expect(interrupt).toHaveBeenCalledWith({ sessionId }); + expect(finalizeResolverSession).toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 64fc48685..2ca032080 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -2872,6 +2872,57 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("restores the idle summary and composer after send dispatch fails", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { send } = installAdeMocks({ + sessions: [session], + sendError: new Error("send failed"), + }); + + renderPane(session); + + const textbox = await screen.findByRole("textbox"); + const getSummary = vi.mocked(window.ade.agentChat.getSummary); + getSummary.mockClear(); + fireEvent.change(textbox, { target: { value: "Retry this idle turn." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(send).toHaveBeenCalled(); + expect(getSummary).toHaveBeenCalledWith({ sessionId: session.sessionId }); + expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("Retry this idle turn."); + }); + }); + + it("restores the backend summary and composer after steer dispatch fails", async () => { + const activeSession = buildSession("session-1", { status: "active" }); + const idleSession = buildSession("session-1", { + status: "idle", + currentTurnStartedAt: null, + }); + const { list, steer } = installAdeMocks({ + sessions: [activeSession], + steerError: new Error("steer failed"), + transcript: buildStatusStartedTranscript(activeSession.sessionId), + }); + + renderTabbedPane(activeSession); + + const textbox = await screen.findByRole("textbox"); + await screen.findByLabelText("Agent working"); + list.mockClear(); + list.mockResolvedValue([idleSession]); + fireEvent.change(textbox, { target: { value: "Retry this active turn." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send steer message" })); + + await waitFor(() => { + expect(steer).toHaveBeenCalled(); + expect(list).toHaveBeenCalled(); + expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("Retry this active turn."); + expect(screen.getByLabelText("Ready for next prompt")).toBeTruthy(); + }); + }); + it("shows an optimistic queued bubble immediately for Cursor-style sends", async () => { const session = buildSession("session-1", { status: "idle" }); let resolveSend!: () => void; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index af7368942..2fc4c709c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -10267,6 +10267,7 @@ export function AgentChatPane({ setBuiltInBrowserContextItems((current) => (current.length ? current : builtInBrowserContextSnapshot)); setOptimisticOutgoingMessageSynced(null); setError(message); + await refreshSessions({ force: true }).catch(() => {}); if ( /ade chat could not authenticate/i.test(message) || /not authenticated/i.test(message) diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index 92c7c16de..4f0fd680d 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -37,6 +37,7 @@ describe("ADE bootstrap guidance", () => { expect(bootstrap).toContain("tracked provider CLIs"); expect(bootstrap).toContain('ade chat note "testing desktop auth fallback"'); expect(bootstrap).toContain("use 3–6 words"); + expect(bootstrap).toContain("72 characters"); expect(bootstrap).toContain('ade chat ask ""'); expect(bootstrap).toContain("a note alone can leave an idle row looking Done"); expect(bootstrap).toContain("The next accepted user message clears the prior hand-raise"); @@ -75,6 +76,7 @@ describe("ADE bootstrap guidance", () => { "ade chat note", "ade chat ask", "3–6 words", + "72 characters", "next accepted user message clears the prior hand-raise", "You cannot settle or unsettle a session", ]) { diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 37fd567dc..84a91d1bb 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -25,7 +25,7 @@ export const adeBundledAgentSkills = [ */ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "ADE control protocol for truthful Work status:", - '- Working: `ade chat note "testing desktop auth fallback"`; use 3–6 words, concretely. Longer notes truncate.', + '- Working: `ade chat note "testing desktop auth fallback"`; use 3–6 words and at most 72 characters. Longer notes truncate.', '- Blocked on input: call `ade chat note ""`, then `ade chat ask ""`; a note alone can leave an idle row looking Done.', "- The next accepted user message clears the prior hand-raise. Re-note and re-ask before ending if still blocked.", '- Done: report it and leave `ade chat note ""`.', diff --git a/apps/desktop/src/shared/sessionStatusNote.test.ts b/apps/desktop/src/shared/sessionStatusNote.test.ts new file mode 100644 index 000000000..fd6baba6e --- /dev/null +++ b/apps/desktop/src/shared/sessionStatusNote.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSessionStatusNote } from "./sessionStatusNote"; + +describe("normalizeSessionStatusNote", () => { + it("keeps the ellipsis inside the 72-character cap when extra words follow an exact boundary", () => { + const exactSixWordBoundary = [ + "abcdefghijkl", + "abcdefghijkl", + "abcdefghijkl", + "abcdefghijkl", + "abcdefghijkl", + "abcdefg", + ].join(" "); + expect(Array.from(exactSixWordBoundary)).toHaveLength(72); + + const normalized = normalizeSessionStatusNote(`${exactSixWordBoundary} extra words`); + + expect(normalized).toBe(`${Array.from(exactSixWordBoundary).slice(0, 71).join("")}…`); + expect(Array.from(normalized ?? "")).toHaveLength(72); + }); +}); diff --git a/apps/desktop/src/shared/sessionStatusNote.ts b/apps/desktop/src/shared/sessionStatusNote.ts index e13c3da0a..83157877e 100644 --- a/apps/desktop/src/shared/sessionStatusNote.ts +++ b/apps/desktop/src/shared/sessionStatusNote.ts @@ -12,9 +12,12 @@ export function normalizeSessionStatusNote(value: unknown): string | null { const words = boundedInput.split(/\s+/); const wordSummary = words.slice(0, MAX_STATUS_NOTE_WORDS).join(" "); const characters = Array.from(wordSummary); + const wasTruncated = + words.length > MAX_STATUS_NOTE_WORDS + || characters.length > MAX_STATUS_NOTE_CHARACTERS; - if (characters.length > MAX_STATUS_NOTE_CHARACTERS) { + if (wasTruncated) { return `${characters.slice(0, MAX_STATUS_NOTE_CHARACTERS - 1).join("")}…`; } - return words.length > MAX_STATUS_NOTE_WORDS ? `${wordSummary}…` : wordSummary; + return wordSummary; } diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index b758a4bde..41bf09b4a 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -199,7 +199,8 @@ export type TerminalSessionSummary = { * optional for migration tolerance; nullable-ISO semantics match * lastActivityAt. settledAt presence = the settled tier (activity clears it * at the write site). statusNote is the agent-authored glanceable status line, - * normalized to at most six words (and used as the outcome once settled). + * normalized to 3–6 words and at most 72 characters (and used as the outcome + * once settled). * attentionRequestedAt/-Message carry an * `ade chat ask` escalation for chat sessions. lastTurnFailedAt marks a chat * turn that died on a runtime/API error (cleared on next turn start). diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 43f855341..b465066ab 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -67,7 +67,7 @@ Regular chat and tracked CLI agents can also narrate their lifecycle directly into the Work list: - `ade chat note "testing desktop auth fallback"` updates the row's quiet - status line, normalized to at most six words; + status line, normalized to 3–6 words and at most 72 characters; an empty note clears it. - `ade chat ask "Which account should I use?"` creates a loud, persisted `Needs you` state, clears settle, and sends a time-sensitive push. The next diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 62fa3d43f..e4bb12850 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -1276,9 +1276,9 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. not dismiss the user's pending question. Provider structured input carries its own pending item id. OSC markers and prompt-looking output never create `Needs you`. `ade chat note ""` clears - only the status line. Status notes are normalized to at most six words at - the session-service boundary so the sidebar remains glanceable; blocking - detail belongs in the separate `ade chat ask` question. + only the status line. Status notes are normalized to 3–6 words and at most + 72 characters at the session-service boundary so the sidebar remains + glanceable; blocking detail belongs in the separate `ade chat ask` question. Beyond the binary settle there is a tri-state **settle override** (`terminal_sessions.settle_override`). `"settled"` behaves like a declared From e61031d93a445c0b765b60d9137eff5b387da6b1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:45:31 -0400 Subject: [PATCH 8/9] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20fix=20P?= =?UTF-8?q?R=20reply=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/services/adeActions/registry.test.ts | 84 +++++++++++++++++++ .../src/main/services/adeActions/registry.ts | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 8cedca477..af556b55c 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -805,6 +805,90 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(messageSession).toHaveBeenCalledTimes(1); }); + it("routes running PR AI action input through human steer semantics and keeps idle input on send", async () => { + vi.useFakeTimers(); + let sessionStatus: "running" | "idle" = "running"; + const sessionId = "pr-ai-action-session-1"; + const sendMessage = vi.fn().mockResolvedValue(undefined); + const steerUserMessage = vi.fn().mockResolvedValue({ steerId: "steer-1", queued: true }); + const interrupt = vi.fn().mockResolvedValue(undefined); + const finalizeResolverSession = vi.fn().mockResolvedValue(undefined); + + try { + const services = getAdeActionDomainServices({ + prService: {}, + eventBuffer: { push: vi.fn() }, + logger: { warn: vi.fn(), debug: vi.fn() }, + agentChatService: { + createSession: vi.fn().mockResolvedValue({ id: sessionId }), + sendMessage, + steerUserMessage, + interrupt, + }, + conflictService: { + prepareResolverSession: vi.fn().mockResolvedValue({ + runId: "resolver-run-1", + status: "ready", + cwdLaneId: "lane-target", + integrationLaneId: null, + promptFilePath: path.resolve("package.json"), + contextGaps: [], + }), + attachResolverSession: vi.fn().mockResolvedValue(undefined), + finalizeResolverSession, + }, + sessionService: { + get: vi.fn(() => ({ + id: sessionId, + status: sessionStatus, + exitCode: null, + })), + }, + } as never); + const pr = services.pr as { + aiResolutionStart: (args: unknown) => Promise<{ sessionId: string; status: string }>; + aiResolutionInput: (args: unknown) => Promise; + aiResolutionStop: (args: unknown) => Promise; + }; + + await expect(pr.aiResolutionStart({ + context: { + sourceTab: "normal", + sourceLaneId: "lane-source", + targetLaneId: "lane-target", + }, + model: "openai/gpt-5.4", + })).resolves.toMatchObject({ sessionId, status: "started" }); + sendMessage.mockClear(); + + await pr.aiResolutionInput({ + sessionId, + text: "Keep the user's attention lifecycle.", + }); + expect(steerUserMessage).toHaveBeenCalledWith({ + sessionId, + text: "Keep the user's attention lifecycle.", + }); + expect(sendMessage).not.toHaveBeenCalled(); + + sessionStatus = "idle"; + await pr.aiResolutionInput({ + sessionId, + text: "Start the next resolver turn.", + }); + expect(sendMessage).toHaveBeenCalledWith({ + sessionId, + text: "Start the next resolver turn.", + }); + + await pr.aiResolutionStop({ sessionId }); + expect(interrupt).toHaveBeenCalledWith({ sessionId }); + expect(finalizeResolverSession).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("resolves chat fileSearch lanes from getSessionSummary and caches the result", async () => { const sessionId = "file-search-summary-session"; const getSessionSummary = vi.fn(async (id: string) => ({ sessionId: id, laneId: "lane-fast" })); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index d7957815a..6d08b76be 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -3364,7 +3364,7 @@ function getPrAiRuntimeBridge(runtime: AdeRuntime): PrAiRuntimeBridge { const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); const sessionDetail = runtime.sessionService.get(sessionId); if (sessionDetail?.status === "running") { - await agentChatService.steer({ sessionId, text }); + await agentChatService.steerUserMessage({ sessionId, text }); return; } await agentChatService.sendMessage({ sessionId, text }); From eb8fa83bebbf1dbce2c6f153e5c2b388e33d4fc2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:17:36 -0400 Subject: [PATCH 9/9] =?UTF-8?q?ship:=20iteration=205=20=E2=80=94=20address?= =?UTF-8?q?=20final=20review=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ade-cli/src/adeRpcServer.test.ts | 11 ++-- .../src/headlessLinearServices.test.ts | 4 ++ .../github/GitHubAppInstallPanel.test.tsx | 52 +++++++++++++++++++ .../github/GitHubAppInstallPanel.tsx | 2 +- 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 106fc8f76..6922bcf0d 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2329,11 +2329,12 @@ describe("adeRpcServer", () => { }), ); const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; - expect(createCall?.env).not.toHaveProperty(ADE_BUNDLED_AGENT_SKILLS_DIR_ENV); - expect(createCall?.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\"", "-c", "service_tier=\"default\""])); - expect(createCall?.args).not.toContain(expect.stringContaining("fix failing tests")); - expect(createCall?.initialInput).toContain("fix failing tests"); - expect(createCall?.initialInputDelayMs).toBe(750); + expect(createCall).toBeDefined(); + expect(createCall!.env).not.toHaveProperty(ADE_BUNDLED_AGENT_SKILLS_DIR_ENV); + expect(createCall!.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\"", "-c", "service_tier=\"default\""])); + expect(createCall!.args).not.toContain(expect.stringContaining("fix failing tests")); + expect(createCall!.initialInput).toContain("fix failing tests"); + expect(createCall!.initialInputDelayMs).toBe(750); expect(fixture.runtime.ptyService.writeBySessionId).not.toHaveBeenCalled(); expect(fixture.runtime.sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-1", diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index c01fd621d..a58bb884c 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -675,8 +675,10 @@ describe("headlessLinearServices", () => { const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; const previousGitHubToken = process.env.GITHUB_TOKEN; const previousGhToken = process.env.GH_TOKEN; + const previousGhConfigDir = process.env.GH_CONFIG_DIR; const previousFetch = globalThis.fetch; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-")); + process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-gh-config-")); delete process.env.ADE_GITHUB_TOKEN; delete process.env.GITHUB_TOKEN; delete process.env.GH_TOKEN; @@ -736,6 +738,8 @@ describe("headlessLinearServices", () => { else process.env.GITHUB_TOKEN = previousGitHubToken; if (previousGhToken == null) delete process.env.GH_TOKEN; else process.env.GH_TOKEN = previousGhToken; + if (previousGhConfigDir == null) delete process.env.GH_CONFIG_DIR; + else process.env.GH_CONFIG_DIR = previousGhConfigDir; } }); diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx new file mode 100644 index 000000000..d3e957ae6 --- /dev/null +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx @@ -0,0 +1,52 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { GitHubAppInstallationStatus } from "../../../shared/types"; +import { GitHubAppInstallPanel } from "./GitHubAppInstallPanel"; + +describe("GitHubAppInstallPanel", () => { + const originalAde = window.ade; + + afterEach(() => { + cleanup(); + window.ade = originalAde; + }); + + it("does not claim account authorization while a repo check is rate limited", async () => { + const rateLimitedStatus: GitHubAppInstallationStatus = { + repo: { owner: "arul28", name: "ADE" }, + appName: "ADE", + appSlug: "ade-for-github", + installUrl: "https://github.com/apps/ade-for-github/installations/new", + manageUrl: "https://github.com/settings/installations", + relayConfigured: true, + installed: false, + state: "error", + installationId: null, + repositorySelection: null, + lastSeenAt: null, + webhookEvents: [], + missingWebhookEvents: [], + webhookState: "unknown", + webhookLastSeenAt: null, + checkedAt: new Date().toISOString(), + error: "GitHub API rate limit reached", + }; + window.ade = { + github: { + getAppInstallationStatus: vi.fn(async () => rateLimitedStatus), + getAppUserAuthStatus: vi.fn(async () => null), + }, + } as unknown as typeof window.ade; + + render(); + + expect(await screen.findByText("Rate limited")).toBeTruthy(); + expect(screen.getByText("Not authorized")).toBeTruthy(); + expect(screen.getByText("GitHub temporarily paused automatic App checks. Wait for the cooldown, then recheck.")).toBeTruthy(); + expect(screen.queryByText(/still authorized/i)).toBeNull(); + expect(screen.queryByText(/re-authorizing is not needed/i)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx index b1e4a177a..b5cc515a7 100644 --- a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx @@ -487,7 +487,7 @@ function repoView( if (isGithubRateLimitMessage(error)) { return { pill: { tone: "warn", color: COLORS.warning, label: "Rate limited" }, - subtext: "GitHub temporarily paused automatic App checks. ADE is still authorized; wait for the cooldown, then recheck. Re-authorizing is not needed.", + subtext: "GitHub temporarily paused automatic App checks. Wait for the cooldown, then recheck.", }; } return {