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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ ade chat read session-id --limit 20 --max-chars 8000 --text
ade chat read session-id --page --cursor 4096 --limit 20 --max-chars 8000 --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 Work status (3–6 words, max 72 characters); add --session <id> to target explicitly
ade chat note "testing desktop auth fallback" # update Work status (aim for 6 words or fewer; truncated past 72 characters); add --session <id> to target explicitly
ade chat ask "Which account should I use?" # escalate a blocking question; add --session <id> 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
Expand Down
6 changes: 5 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
type DoctorRow,
} from "./commands/doctor";
export { readInstalledDesktopVersion } from "./commands/doctor";
import {
MAX_STATUS_NOTE_CHARACTERS,
STATUS_NOTE_GUIDELINE_WORDS,
} from "../../desktop/src/shared/sessionStatusNote";
import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/deeplinks";
import { buildPairingQrPayload } from "../../desktop/src/shared/pairingQr";
import { buildWebClientPairUrl } from "../../desktop/src/shared/webClientUrl";
Expand Down Expand Up @@ -1743,7 +1747,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat create --from-linear-issue ENG-431 --parent <session> --type subagent
Start a child chat with an attached issue + kickoff (alias: --linear-issue-json)
$ ade chat send <session> --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 (3–6 words, max 72 characters)
$ ade chat note "testing desktop auth fallback" # Update the Work status line (aim for ${STATUS_NOTE_GUIDELINE_WORDS} words or fewer; truncated past ${MAX_STATUS_NOTE_CHARACTERS} 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 <id>.
'chat settle' / 'chat unsettle' were removed: only the user (or a
Expand Down
3 changes: 2 additions & 1 deletion apps/ade-cli/src/services/sync/rosterBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ describe("buildRosterSnapshot", () => {

expect(byId.get("chat-run")).toMatchObject({
settledAt: "2026-01-02T00:01:00Z",
statusNote: "Indexing complete and waiting for final…",
// Eight words survive: the note only truncates past 72 characters.
statusNote: "Indexing complete and waiting for final review now",
exitCode: null,
});
expect(byId.get("chat-await")).toMatchObject({
Expand Down
21 changes: 21 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1379,3 +1379,24 @@ describe("RightPane feedback form", () => {
expect(draft.summary).toBe("add dark mode");
});
});

describe("RightPane list rows", () => {
it("clips a long chat row to the pane instead of wrapping it onto extra lines", () => {
// A status note is bounded at 72 characters, not at six words, so a chat
// row can easily exceed the 38-column pane. It must clip, not wrap.
const longRow = "● claude-auth-login · claude · done: reworked automatic naming so the note keeps its meaning";
const content = {
kind: "list" as const,
title: "Chats",
rows: [longRow, "● short row"],
action: { kind: "chat-list" as const, ids: ["a", "b"] },
};
const result = render(<RightPane content={content} focused width={38} />);
const lines = stripAnsi(result.lastFrame() ?? "").split("\n");
const rowLines = lines.filter((line) => line.includes("claude-auth-login"));
expect(rowLines).toHaveLength(1);
expect(rowLines[0]?.length).toBeLessThanOrEqual(38);
expect(rowLines[0]).toContain("…");
expect(lines.some((line) => line.includes("short row"))).toBe(true);
});
});
9 changes: 8 additions & 1 deletion apps/ade-cli/src/tuiClient/components/RightPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2385,14 +2385,21 @@ function RightPaneComponent({
// list) can't scroll past the content into a blank pane.
const listStart = Math.max(0, Math.min(scrollOffsetRows, Math.max(0, content.rows.length - DETAILS_BODY_MAX_LINES)));
const visibleRows = content.rows.slice(listStart, listStart + DETAILS_BODY_MAX_LINES);
// A row is free text (a chat row carries an agent-authored status
// note up to its full 72-character budget), so it has to be clipped
// to the pane: an unclipped row wraps onto extra lines and pushes
// the "N more" footer and every row below it out of the window.
const rowWidth = Math.max(8, paneWidth - 4);
return content.rows.length ? visibleRows.map((row, visibleIndex) => {
const index = listStart + visibleIndex;
const prefix = content.action ? `${index === selectedIndex ? theme.rail : " "} ` : "";
return (
<Text
key={`${content.action?.ids[index] ?? row}:${index}`}
wrap="truncate-end"
color={content.action && (index === selectedIndex || hoveredId === `right:list:${index}`) ? theme.color.violet : undefined}
>
{content.action ? `${index === selectedIndex ? theme.rail : " "} ${row}` : row}
{`${prefix}${endTruncate(row, Math.max(4, rowWidth - prefix.length))}`}
</Text>
);
}) : <Text color={theme.color.t4} dimColor>{content.emptyText ?? "No data."}</Text>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ row. If you are blocked, `ade chat ask "<question>"` 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 and at most 72 characters**. ADE
truncates longer notes, so put the decisive state first and never write a
full sentence.
present-tense summary aiming for **6 words or fewer** — a guideline, not a
hard limit. ADE truncates past **72 characters**, so put the decisive state
first and never write a full sentence, but a long note still beats no note.
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
Expand Down
147 changes: 142 additions & 5 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,45 @@ function createService(overrides: Record<string, unknown> = {}) {
return { service, logger, laneService, sessionService, projectConfigService, aiIntegrationService };
}

async function waitFor(condition: () => boolean, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}

function installAutoTitleAuth(): void {
// Auto-titling is skipped outright when no model is reachable.
vi.mocked(detectAllAuth).mockResolvedValue([
{ type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true },
{ type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true },
] as never);
}

function installAutoTitleClaudeStream(): void {
let streamCall = 0;
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send: vi.fn().mockResolvedValue(undefined),
stream: vi.fn(() => (async function* () {
streamCall += 1;
if (streamCall === 1) {
yield { type: "system", subtype: "init", session_id: "sdk-session-1", slash_commands: [] };
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
return;
}
yield {
type: "assistant",
message: { content: [{ type: "text", text: "Done" }], usage: { input_tokens: 1, output_tokens: 1 } },
};
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
})()),
close: vi.fn(),
sessionId: "sdk-session-1",
setPermissionMode: vi.fn().mockResolvedValue(undefined),
} as any);
}

const HANDOFF_TEST_SHA = "1234567890abcdef1234567890abcdef12345678";
const HANDOFF_BEHIND_SHA = "0123456789abcdef0123456789abcdef01234567";
const HANDOFF_DIVERGED_SHA = "fedcba9876543210fedcba9876543210fedcba98";
Expand Down Expand Up @@ -13426,6 +13465,76 @@ describe("createAgentChatService", () => {

expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled();
});

it("does not clobber a manual rename that lands while auto-titling is in flight", async () => {
const events: AgentChatEventEnvelope[] = [];
installAutoTitleClaudeStream();
installAutoTitleAuth();

let renameDuringNaming: Promise<unknown> | null = null;
const { service, sessionService, aiIntegrationService } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" });

// The naming request only resolves after the user has renamed the chat,
// which is exactly the race that used to overwrite their title and clear
// the manuallyNamed flag.
aiIntegrationService.summarizeTerminal.mockImplementation(async () => {
if (renameDuringNaming) return { text: "Model Picked That" } as never;
renameDuringNaming = service.updateSession({
sessionId: session.id,
title: "User Picked This",
manuallyNamed: true,
});
await renameDuringNaming;
return { text: "Model Picked That" } as never;
});

await service.sendMessage({ sessionId: session.id, text: "Build me a new feature" });
await waitForEvent(events, (event): event is AgentChatEventEnvelope => event.event.type === "done");
await waitFor(() => Boolean(renameDuringNaming));
await renameDuringNaming;
// Wait for the clobber itself rather than a fixed delay: pre-fix code
// writes the model title as soon as the naming call resolves, so this
// returns immediately when the regression is present and costs a bounded
// wait when it is not.
await waitFor(() => sessionService.get(session.id)?.title === "Model Picked That", 1_000);

expect(renameDuringNaming, "auto-title never ran, so the race was not exercised").not.toBeNull();
expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled();
expect(sessionService.get(session.id)?.title).toBe("User Picked This");
expect(sessionService.get(session.id)?.manuallyNamed).toBe(true);
expect(sessionService.updateMeta).not.toHaveBeenCalledWith(
expect.objectContaining({ title: "Model Picked That" }),
);
});

it("falls back to a deterministic title when every naming model fails", async () => {
const events: AgentChatEventEnvelope[] = [];
installAutoTitleClaudeStream();
installAutoTitleAuth();

const { service, sessionService, aiIntegrationService } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
// A provider-level failure condemns each provider in turn, so the chain
// runs out of models — the chat must still never sit on "Claude Chat".
aiIntegrationService.summarizeTerminal.mockRejectedValue(
new Error("The model is not supported when using Codex with a ChatGPT account."),
);

const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" });
await service.sendMessage({ sessionId: session.id, text: "Rewrite the lane naming fallback chain" });
await waitForEvent(events, (event): event is AgentChatEventEnvelope => event.event.type === "done");
await waitFor(() => (sessionService.get(session.id)?.title ?? "") !== "Claude Chat");

const title = sessionService.get(session.id)?.title ?? "";
expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled();
expect(title).not.toBe("Claude Chat");
expect(title.split(/\s+/).filter(Boolean).length).toBeGreaterThanOrEqual(2);
expect(title.toLowerCase()).toContain("lane");
});
});

// --------------------------------------------------------------------------
Expand Down Expand Up @@ -37729,10 +37838,36 @@ describe("suggestLaneNameFromPrompt", () => {
});

expect(result.laneTitle).toBe("Claude OAuth Login");
expect(result.branchFragment).toBe("claude-auth-login-button");
expect(result.branchFragment).toBe("claude-auth-login-button-hangs");
});

it("clamps an over-long AI identity instead of discarding it for a slug", async () => {
vi.mocked(detectAllAuth).mockResolvedValue([
{ type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true },
]);
const { service, aiIntegrationService } = createSuggestService();
// Six words is guidance for the model, not a gate: a seven-word answer is
// trimmed, never thrown away in favour of the deterministic slug.
vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValueOnce({
text: JSON.stringify({
laneTitle: "Rework Session Naming Fallback Chain For Chats",
branchFragment: "rework-session-naming-fallback-chain-for-chats",
}),
} as any);

const result = await service.generateAutoLaneIdentity({
prompt: "Rework the session naming fallback chain",
modelId: "openai/gpt-5.4",
laneId: "lane-1",
temporaryBranch: "ade/1a2b3c4d",
});

expect(result.source).toBe("ai");
expect(result.laneTitle).toBe("Rework Session Naming Fallback Chain For");
expect(result.branchFragment).toBe("rework-session-naming-fallback-chain-for");
});

it("treats fully invalid structured fields as deterministic fallback", async () => {
it("retries the next model when structured fields are unusable, then falls back deterministically", async () => {
vi.mocked(detectAllAuth).mockResolvedValue([
{ type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true },
]);
Expand All @@ -37749,11 +37884,13 @@ describe("suggestLaneNameFromPrompt", () => {
});

expect(result).toMatchObject({
laneTitle: "Claude Auth Login Button",
branchFragment: "claude-auth-login-button",
laneTitle: "Claude Auth Login Button Hangs",
branchFragment: "claude-auth-login-button-hangs",
source: "deterministic",
});
expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(1);
// An unusable answer no longer ends the chain: the remaining candidates
// still get a turn before naming settles for the deterministic slug.
expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(3);
});

it("uses the configured naming model before the launched model", async () => {
Expand Down
Loading
Loading