Skip to content
Merged
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ This builds the ADE CLI, refreshes the shared dev runtime when needed, launches
the Electron desktop app, and points desktop at that runtime. For renderer-only
UI work, see [apps/desktop/README.md](apps/desktop/README.md).

`npm run dev` also works from a lane checkout under `.ade/worktrees/<lane>`. To
run a lane build **in isolation** β€” its own runtime + bridge sockets, without
restarting your installed app's runtime β€” follow
[Run a specific lane worktree](README.md#run-a-specific-lane-worktree) in the root
README. Key rule: never aim `dev:desktop --socket` at a runtime you do not want
`--auto` to shut down; use a fresh per-lane `/tmp/ade-runtime-<lane>.sock`.

## Before Submitting

- Run the smallest relevant checks for your change first
Expand Down
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,43 @@ ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/my-ade-dev.sock npm run dev:runtime
ADE_DESKTOP_BRIDGE_SOCKET_PATH=/tmp/my-bridge.sock npm run dev:desktop
```

When launching ADE desktop dev through ADE App Control from a running Alpha/Beta
ADE window, use an absolute lane cwd and clear packaged-channel environment
variables inherited from the host app. Otherwise the dev Electron app can reuse
the Alpha/Beta profile and lose the single-instance lock instead of opening the
lane build:
> [!WARNING]
> Never point `--socket` at a runtime you do not want restarted. In the default
> `--auto` mode the wrapper **shuts down and recreates** whatever runtime is
> already listening on that socket whenever its build hash does not match the
> checkout you are launching β€” so aiming at the production `~/.ade/sock/ade.sock`
> or another lane's live runtime will kill it (and any clients attached to it).
> Point at a fresh per-lane socket (below), or use
> `npm run dev:desktop:attach -- --socket <path>` to connect to an already-running
> runtime β€” attach mode refuses on a build-hash mismatch instead of restarting.

### Run a specific lane worktree

To preview a lane's build without disturbing your installed ADE app or its
runtime, run `dev:desktop` **from the lane checkout** on its own sockets. Running
from the worktree makes Vite serve that lane's code, while the wrapper
auto-resolves project *data* to the primary checkout (as described above), so you
see the lane's UI backed by your real lanes, PRs, and chats:

```bash
cd /path/to/ADE/.ade/worktrees/<lane>
ADE_DESKTOP_BRIDGE_SOCKET_PATH=/tmp/ade-desktop-bridge-<lane>.sock \
npm run dev:desktop -- --socket /tmp/ade-runtime-<lane>.sock
```

The per-lane `--socket` gives the lane build an isolated runtime (and sidesteps
the warning above β€” nothing else is listening there); the per-lane bridge socket
avoids colliding with the installed app's `~/.ade/sock/desktop-bridge.sock`. Set
`ADE_PROJECT_ROOT=/path/to/other-project` only if you want a different project's
data. A fresh worktree has no `node_modules` β€” symlink the root and `apps/desktop`
`node_modules` from the primary checkout, or run `npm run setup` inside the
worktree first.

When launching that same flow through ADE App Control from a running Alpha/Beta
ADE window, also clear the packaged-channel environment variables inherited from
the host app (and use an absolute lane cwd). Otherwise the dev Electron app can
reuse the Alpha/Beta profile and lose the single-instance lock instead of opening
the lane build:

```bash
ade --socket app-control launch --force \
Expand Down
46 changes: 44 additions & 2 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ import { createProcessService } from "../../desktop/src/main/services/processes/
import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "../../desktop/src/main/services/ai/cliExecutableResolver";
import { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService";
import type { createPrService } from "../../desktop/src/main/services/prs/prService";
import type { createPrSummaryService } from "../../desktop/src/main/services/prs/prSummaryService";
import type { createQueueLandingService } from "../../desktop/src/main/services/prs/queueLandingService";
import { createPrSummaryService } from "../../desktop/src/main/services/prs/prSummaryService";
import { createQueueLandingService } from "../../desktop/src/main/services/prs/queueLandingService";
import { createIssueInventoryService } from "../../desktop/src/main/services/prs/issueInventoryService";
import { createPathToMergeOrchestrator } from "../../desktop/src/main/services/prs/pathToMergeOrchestrator";
import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService";
Expand Down Expand Up @@ -116,6 +116,7 @@ import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowse
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import type { createFileService } from "../../desktop/src/main/services/files/fileService";
import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types";
import type { PrEventPayload } from "../../desktop/src/shared/types/prs";
import {
createAutomationService,
type AutomationAdeActionRegistry,
Expand Down Expand Up @@ -1139,6 +1140,45 @@ export async function createAdeRuntime(args: {
automationService,
})
: null;

// PR queue-landing + AI-summary services. These live on dedicated services
// (not on prService), so without wiring them here the runtime `pr` domain
// omits `listQueueStates`/queue-automation/summary actions and the desktop's
// `pr.listQueueStates` call over the local runtime fails with "is not
// callable". Mirror the desktop main-process wiring (see main.ts) so the PRs
// tab loads against the local runtime.
const emitPrEvent = (event: PrEventPayload): void => {
pushEvent("runtime", { type: "pr_event", event });
};
const queueLandingService = createQueueLandingService({
db,
logger,
projectId,
prService: headlessLinearServices.prService,
laneService,
conflictService,
emitEvent: emitPrEvent,
onStateChanged: (state) => {
const hotPrIds = new Set<string>();
const currentEntry = state.entries[state.currentPosition];
const nextEntry = state.entries[state.currentPosition + 1];
if (state.activePrId) hotPrIds.add(state.activePrId);
if (currentEntry?.prId) hotPrIds.add(currentEntry.prId);
if (nextEntry?.prId) hotPrIds.add(nextEntry.prId);
if (hotPrIds.size > 0) {
headlessLinearServices.prService.markHotRefresh(Array.from(hotPrIds));
}
},
});
queueLandingService.init();
const prSummaryService = createPrSummaryService({
db,
logger,
projectRoot,
prService: headlessLinearServices.prService,
aiIntegrationService,
});

const usageTrackingService = createUsageTrackingService({
logger,
pollIntervalMs: 120_000,
Expand Down Expand Up @@ -1300,6 +1340,8 @@ export async function createAdeRuntime(args: {
linearCredentialService: headlessLinearServices.linearCredentialService,
linearOAuthService,
prService: headlessLinearServices.prService,
queueLandingService,
prSummaryService,
fileService: headlessLinearServices.fileService,
flowPolicyService: headlessLinearServices.flowPolicyService,
linearDispatcherService: headlessLinearServices.linearDispatcherService,
Expand Down
9 changes: 7 additions & 2 deletions apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ function stripAnsi(text: string): string {
}

import type { ChatInfoSnapshot } from "../types";
import { resolveSubagentCapability } from "../../../../desktop/src/shared/subagentCapabilities";

function chatInfo(overrides: Partial<ChatInfoSnapshot> = {}): ChatInfoSnapshot {
const provider = overrides.provider ?? "codex";
return {
provider: "codex",
provider,
modelLabel: "gpt-5.5-high",
laneLabel: "fixing-cli-send-error",
contextPercent: 42,
Expand All @@ -46,6 +48,8 @@ function chatInfo(overrides: Partial<ChatInfoSnapshot> = {}): ChatInfoSnapshot {
},
snapshots: [],
inspectedSubagentId: null,
capability: resolveSubagentCapability(provider),
mission: null,
...overrides,
};
}
Expand Down Expand Up @@ -86,7 +90,8 @@ describe("RightPane chat info", () => {
expect(frame).toContain("Ship CLI parity");
expect(frame).toContain("CHATS");
expect(frame).toContain("delegated");
expect(frame).toContain("↑↓ focus Β· ↡ swap Β· esc β†’ main");
// Codex can view full subagent transcripts β†’ Enter takes over the main chat.
expect(frame).toContain("↑↓ focus Β· ↡ open thread Β· esc β†’ main");
expect(frame).not.toContain("Errors");
expect(frame).not.toContain("Activity");
expect(frame).not.toContain("tab Β· cycle");
Expand Down
9 changes: 7 additions & 2 deletions apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
} from "../app";
import { clampTerminalPaneCols } from "../components/TerminalPane";
import type { ChatInfoSnapshot } from "../types";
import { resolveSubagentCapability } from "../../../../desktop/src/shared/subagentCapabilities";
import type { AgentChatSession, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat";
import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes";
import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions";
Expand Down Expand Up @@ -392,6 +393,8 @@ describe("right pane context defaults", () => {
snapshots: [],
inspectedSubagentId: null,
streaming: false,
capability: resolveSubagentCapability("claude"),
mission: null,
};
}

Expand Down Expand Up @@ -885,8 +888,10 @@ describe("pane width helpers", () => {
expect(clampTerminalPaneCols(Number.POSITIVE_INFINITY)).toBe(20);
});

it("does not further cap chat text when both side panes already narrow the center", () => {
expect(resolveChatWrapWidth(72, true, 34)).toBe(72);
it("reserves a right gutter when the details pane is open so chat text does not hug the border", () => {
expect(resolveChatWrapWidth(72, true, 34)).toBe(70); // open pane β†’ 2-col gutter
expect(resolveChatWrapWidth(72, true, 0)).toBe(72); // closed pane β†’ no gutter
expect(resolveChatWrapWidth(24, true, 34)).toBe(24); // gutter never underflows the min
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
coalesceTextDeltaEnvelopes,
isCodexSubagentMessageId,
shouldMergeAssistantText,
} from "../assistantTextIdentity";
import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat";

function textDelta(seq: number, text: string, ids: { messageId?: string; turnId?: string; itemId?: string }): AgentChatEventEnvelope {
return {
sessionId: "s1",
timestamp: `2026-01-01T12:00:${String(seq).padStart(2, "0")}.000Z`,
sequence: seq,
event: { type: "text", text, ...ids },
};
}

describe("shouldMergeAssistantText", () => {
it("merges same messageId, never across different messages", () => {
expect(shouldMergeAssistantText({ messageId: "a" }, { messageId: "a" })).toBe(true);
expect(shouldMergeAssistantText({ messageId: "a" }, { messageId: "b" })).toBe(false);
});
it("falls back to turn+item, and merges identity-less deltas", () => {
expect(shouldMergeAssistantText({ turnId: "t", itemId: "i" }, { turnId: "t", itemId: "i" })).toBe(true);
expect(shouldMergeAssistantText({ turnId: "t", itemId: "i" }, { turnId: "t", itemId: "j" })).toBe(false);
expect(shouldMergeAssistantText({}, {})).toBe(true);
});
});

describe("isCodexSubagentMessageId", () => {
it("matches only the codex-subagent namespace", () => {
expect(isCodexSubagentMessageId("codex-subagent:x:y:z:text")).toBe(true);
expect(isCodexSubagentMessageId("msg_123")).toBe(false);
expect(isCodexSubagentMessageId(undefined)).toBe(false);
});
});

describe("coalesceTextDeltaEnvelopes", () => {
it("fuses consecutive same-message deltas into one envelope, byte-identical, latest ts/seq", () => {
const ids = { messageId: "m1", turnId: "t1", itemId: "i1" };
const out = coalesceTextDeltaEnvelopes([
textDelta(1, "I'll do", ids),
textDelta(2, " a read-only", ids),
textDelta(3, " pass.", ids),
]);
expect(out).toHaveLength(1);
expect(out[0]!.event.type).toBe("text");
expect((out[0]!.event as { text: string }).text).toBe("I'll do a read-only pass.");
expect(out[0]!.sequence).toBe(3); // latest, so the timeline sort is unchanged
expect(out[0]!.timestamp).toBe("2026-01-01T12:00:03.000Z");
});

it("keeps interleaved concurrent messages separate (never scrambled)", () => {
const out = coalesceTextDeltaEnvelopes([
textDelta(1, "Plan", { messageId: "A" }),
textDelta(2, "Read", { messageId: "B" }),
textDelta(3, "ning", { messageId: "A" }),
textDelta(4, "ing", { messageId: "B" }),
]);
// No fusion across the message boundary β†’ 4 envelopes preserved in order.
expect(out.map((e) => (e.event as { text: string }).text)).toEqual(["Plan", "Read", "ning", "ing"]);
});

it("does not fuse across a non-text event", () => {
const ids = { messageId: "m1", turnId: "t1", itemId: "i1" };
const out = coalesceTextDeltaEnvelopes([
textDelta(1, "before", ids),
{ sessionId: "s1", timestamp: "2026-01-01T12:00:02.000Z", sequence: 2, event: { type: "tool_call", itemId: "tc", tool: "read_file" } as never },
textDelta(3, "after", ids),
]);
expect(out).toHaveLength(3);
});

it("collapses a large single-message token stream to one envelope (flood guard)", () => {
const ids = { messageId: "m1", turnId: "t1", itemId: "i1" };
const deltas = Array.from({ length: 800 }, (_, i) => textDelta(i + 1, i === 0 ? "x" : " x", ids));
const out = coalesceTextDeltaEnvelopes(deltas);
expect(out).toHaveLength(1);
expect((out[0]!.event as { text: string }).text.startsWith("x x x")).toBe(true);
});
});
40 changes: 40 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,46 @@ describe("renderChatLines", () => {
expect(lines[0]?.header).toBeUndefined();
});

it("keeps concurrent interleaved messages separate and intact, and filters codex-subagent text", () => {
const session = {
sessionId: "s1", laneId: "lane-1", provider: "codex", model: "gpt-5.5", status: "idle",
startedAt: "2026-01-01T12:00:00.000Z", endedAt: null, lastActivityAt: "2026-01-01T12:00:00.000Z",
lastOutputPreview: null, summary: null,
} as const;
// Parent message "A" and a concurrent message "B" stream interleaved by
// timestamp, plus a codex-subagent message that must not reach the parent
// transcript. Each delta carries its own leading space.
const lines = renderChatLines({
activeSession: session,
notices: [],
events: [
{ sessionId: "s1", timestamp: "2026-01-01T12:00:01.000Z", sequence: 1,
event: { type: "text", text: "Planning the", messageId: "msg-A", turnId: "t1", itemId: "iA" } },
{ sessionId: "s1", timestamp: "2026-01-01T12:00:01.100Z", sequence: 2,
event: { type: "text", text: "Reading the", messageId: "msg-B", turnId: "t1", itemId: "iB" } },
{ sessionId: "s1", timestamp: "2026-01-01T12:00:01.200Z", sequence: 3,
event: { type: "text", text: " architecture pass.", messageId: "msg-A", turnId: "t1", itemId: "iA" } },
{ sessionId: "s1", timestamp: "2026-01-01T12:00:01.300Z", sequence: 4,
event: { type: "text", text: " renderer files.", messageId: "msg-B", turnId: "t1", itemId: "iB" } },
{ sessionId: "s1", timestamp: "2026-01-01T12:00:01.400Z", sequence: 5,
event: { type: "text", text: "Subagent secret leak.", messageId: "codex-subagent:x:y:z:text", turnId: "t1", itemId: "iC" } },
],
});
const assistant = lines.filter((l) => l.tone === "assistant");
// No single line scrambles the two messages together (the old identity-blind
// bug fused interleaved deltas β€” losing word boundaries at the seams).
for (const line of assistant) {
expect(line.body.includes("architecture") && line.body.includes("renderer")).toBe(false);
}
// Re-joining each message's fragments (kept attributed by messageId) yields
// the original, fully-spaced text β€” proving the pipeline never drops spaces.
const joinById = (id: string) => assistant.filter((l) => l.messageId === id).map((l) => l.body).join("");
expect(joinById("msg-A")).toBe("Planning the architecture pass.");
expect(joinById("msg-B")).toBe("Reading the renderer files.");
// codex-subagent content never reaches the parent transcript.
expect(assistant.map((l) => l.body).join("\n")).not.toContain("Subagent secret leak");
});

it("does not coalesce assistant text across a tool call", () => {
const session = {
sessionId: "s1",
Expand Down
Loading
Loading