Skip to content
Closed
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
138 changes: 130 additions & 8 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,26 @@ const execFileAsync = promisify(execFile);

const MAX_TITLE_LENGTH = 256;

/** Extract plain text from a message's content, which may be a raw string or
* an array of content blocks (text / image / tool_use / …). Only text blocks
* are concatenated; all other block types are ignored. */
function extractPlainText(content: unknown): string | null {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const block of content) {
if (
typeof block === "object" &&
block !== null &&
(block as { type: string }).type === "text" &&
typeof (block as { text: unknown }).text === "string"
) {
parts.push((block as { text: string }).text);
}
}
return parts.length > 0 ? parts.join("\n") : null;
}

function sanitizeTitle(text: string): string {
// Replace newlines and collapse whitespace
const sanitized = text
Expand Down Expand Up @@ -561,6 +581,15 @@ type Session = {
* and only notify the client when it actually changes. Undefined until the
* first title is observed. */
lastTitle?: string;
/** AI-generated semantic title produced by calling the Anthropic API with
* the first user message after the first turn. Preferred over the SDK's
* summary (which is just the last user message) in `maybeUpdateSessionTitle`.
* Undefined until generation succeeds; absent when the API key is unavailable
* or the call fails. */
generatedTitle?: string;
/** True once semantic title generation has been attempted (success or failure),
* so we do not retry on every subsequent turn. */
titleGenerationAttempted?: boolean;
/** Caches `tool_use` blocks by id so the matching `tool_result` can recover
* the tool name/input when mapping it to a `tool_call_update`. Per-session
* (tool_use ids are only unique within a session) and pruned at
Expand Down Expand Up @@ -1810,6 +1839,15 @@ export class ClaudeAcpAgent {
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const result = await this.getOrCreateSession(params);

// Push the SDK-maintained title before replaying history so the client
// sees the correct title immediately. Sending it first also prevents the
// history-replay fallback (backfillDerivedTitleIfNeeded on the client)
// from overriding the authoritative title with the first user message.
const session = this.sessions[params.sessionId];
if (session) {
await this.maybeUpdateSessionTitle(params.sessionId, session);
}

await this.replaySessionHistory(params.sessionId);

// Send available commands after replay so it doesn't interleave with history
Expand Down Expand Up @@ -1838,6 +1876,67 @@ export class ClaudeAcpAgent {
};
}

/** Generate a semantic title for a session by calling the Anthropic API with
* the first user message. Sets `session.titleGenerationAttempted` before
* returning so callers know not to retry. Only works when `ANTHROPIC_API_KEY`
* is set; silently no-ops for Bedrock/Vertex/gateway setups. */
private async generateSemanticTitle(
sessionId: string,
session: Session,
): Promise<string | null> {
session.titleGenerationAttempted = true;

const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) return null;

let messages: Awaited<ReturnType<typeof getSessionMessages>>;
try {
messages = await getSessionMessages(sessionId);
} catch (error) {
this.logger.error(`Session ${sessionId}: could not read messages for title generation: ${error}`);
return null;
}

// Extract the first meaningful user message, excluding the IDE context block
// that Claude Code appends after the user's actual text.
let firstUserText: string | null = null;
for (const msg of messages) {
// @ts-expect-error — SDK message content is untyped
if (msg.message?.role !== "user") continue;
// @ts-expect-error — SDK message content is untyped
const raw: unknown = msg.message.content;
const text = extractPlainText(raw);
if (text?.trim()) {
// Strip the appended IDE context so the model sees only the user's intent.
firstUserText = text.split(/\n\n### IDE Context(?:\n|$)/)[0].trim();
if (firstUserText) break;
}
}
if (!firstUserText) return null;

try {
const { default: Anthropic } = await import("@anthropic-ai/sdk");
const client = new Anthropic({
apiKey,
...(process.env.ANTHROPIC_BASE_URL && { baseURL: process.env.ANTHROPIC_BASE_URL }),
});
const response = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 25,
system:
"Generate a concise title (3–6 words) for this coding conversation. " +
"Reply with ONLY the title — no punctuation, no quotes, no explanation.",
messages: [{ role: "user", content: firstUserText.slice(0, 500) }],
});
const text =
response.content[0]?.type === "text" ? response.content[0].text.trim() : null;
return text ? sanitizeTitle(text) : null;
} catch (error) {
this.logger.error(`Session ${sessionId}: semantic title generation failed: ${error}`);
return null;
}
}

/** Read the SDK-maintained title for a session and, if it changed since the
* last time we looked, notify the client with a `session_info_update`. The
* SDK has no push event for the title it auto-generates in the background, so
Expand All @@ -1851,9 +1950,8 @@ export class ClaudeAcpAgent {
this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
return;
}
// `customTitle` is a user-set `/rename`; `summary` is the auto-generated
// title (or first prompt). Prefer the explicit title when present.
const rawTitle = info?.customTitle ?? info?.summary;
// Priority: user-set /rename > AI-generated semantic title > SDK summary (last user message).
const rawTitle = info?.customTitle ?? session.generatedTitle ?? info?.summary;
if (!rawTitle) {
return;
}
Expand Down Expand Up @@ -3283,6 +3381,18 @@ export class ClaudeAcpAgent {
),
);
}
// Generate a semantic title via the Anthropic API once, after
// the first turn, so the tab shows a meaningful label instead
// of the last user message that the SDK uses as its summary.
if (!session.titleGenerationAttempted) {
const generated = await this.generateSemanticTitle(
params.sessionId,
session,
);
if (generated) {
session.generatedTitle = generated;
}
}
// The SDK generates the session title in a background task and
// persists it to the session file; `idle` is the turn-over
// signal, so it's the point at which a new title may have
Expand Down Expand Up @@ -6946,11 +7056,23 @@ export function buildConfigOptions(
category: "model",
type: "select",
currentValue: models.currentModelId,
options: models.availableModels.map((m) => ({
value: m.modelId,
name: m.name,
description: m.description ?? undefined,
})),
options: models.availableModels.map((m) => {
if (m.modelId === "default") {
const defaultInfo = modelInfos.find((mi) => mi.value === "default");
const resolvedModel = defaultInfo?.resolvedModel;
if (resolvedModel) {
const namedMatch = modelInfos.find(
(mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel,
);
return {
value: m.modelId,
name: m.name,
description: namedMatch?.displayName ?? resolvedModel,
};
}
}
return { value: m.modelId, name: m.name, description: m.description ?? undefined };
}),
},
];

Expand Down
77 changes: 77 additions & 0 deletions src/tests/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13462,6 +13462,83 @@ describe("agent selection config option", () => {
});
});

describe("buildConfigOptions model option resolved description", () => {
const modes = { currentModeId: "default", availableModes: [] };
const models = {
currentModelId: "default",
availableModels: [{ modelId: "default", name: "Default", description: "" }],
};

it("sets description to the named model's displayName when resolvedModel matches", () => {
const modelInfos = [
{
value: "default",
displayName: "Default",
description: "",
resolvedModel: "claude-sonnet-5",
},
{
value: "sonnet",
displayName: "Claude Sonnet 5",
description: "Balanced",
resolvedModel: "claude-sonnet-5",
},
];
const options = buildConfigOptions(
modes,
models,
modelInfos as any,
undefined,
[],
"default",
);
const modelOption = options.find((o) => o.id === "model");
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
expect(defaultEntry.description).toBe("Claude Sonnet 5");
});

it("falls back to resolvedModel itself when no named model shares it", () => {
const modelInfos = [
{
value: "default",
displayName: "Default",
description: "",
resolvedModel: "claude-opus-5-20251201",
},
];
const options = buildConfigOptions(
modes,
models,
modelInfos as any,
undefined,
[],
"default",
);
const modelOption = options.find((o) => o.id === "model");
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
expect(defaultEntry.description).toBe("claude-opus-5-20251201");
});

it("leaves description undefined when default model has no resolvedModel", () => {
const modelsNoDesc = {
currentModelId: "default",
availableModels: [{ modelId: "default", name: "Default" }],
};
const modelInfos = [{ value: "default", displayName: "Default", description: "" }];
const options = buildConfigOptions(
modes,
modelsNoDesc,
modelInfos as any,
undefined,
[],
"default",
);
const modelOption = options.find((o) => o.id === "model");
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
expect(defaultEntry.description).toBeUndefined();
});
});

describe("switching the agent", () => {
function createMockAgent() {
const mockClient = { sessionUpdate: async () => {} } as unknown as AcpClient;
Expand Down
Loading