Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
136 changes: 131 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,37 @@ function createService(overrides: Record<string, unknown> = {}) {
return { service, logger, laneService, sessionService, projectConfigService, aiIntegrationService };
}

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 +13457,73 @@ 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 () => {
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");
for (let i = 0; i < 40 && !renameDuringNaming; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
await renameDuringNaming;
await new Promise((resolve) => setTimeout(resolve, 50));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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 new Promise((resolve) => setTimeout(resolve, 50));

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 +37827,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 +37873,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