Files
claude-agent-acp/src/session-notices.ts
Ben Brandt ab2ff616a8 feat: serve the config options, model fallback, context window, conversation reset and clear-context plans on the native v2 core
Port the bridge's effort, Fast mode, ultracode and agent options and their set
paths, the refusal fallback notice and model swap, the context window seed
(getContextUsage and the cross-session cache), conversation_reset handling, and
ExitPlanMode's clear-context option to the native v2 core.

The protocol-neutral code moves out of acp-agent.ts into shared modules that
both cores use: session-config-options.ts, session-fast-mode.ts,
context-window.ts, the effort switch helpers in session-effort.ts,
withCurrentModelInfo, and modelRefusalFallback.

The native core continues an accepted clear-context plan with Claude Code's own
/clear on the same query instead of the bridge's fresh query; the client sees
the same updates.
2026-11-09 13:46:34 +01:00

216 lines
8.9 KiB
TypeScript

import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
/** Fire-and-forget advisory text for the user: live events rather than
* conversation history. See the ACP Session Notices RFD
* (https://agentclientprotocol.com/rfds/session-notices). */
export type SessionNotice = {
severity: "info" | "warning" | "error";
/** Plain text that stands alone. */
title: string;
/** Plain-text detail or guidance. */
description?: string;
};
/** Longest free-form line that may serve as a notice title on its own. Past
* this the text moves to `description` under a generic title. */
export const MAX_NOTICE_TITLE_LENGTH = 256;
/** Whether the Client advertised `clientCapabilities.session.notices`. Agents
* MUST NOT send `notice` updates otherwise; the alternative is a bold-label
* transcript line, which was the only way to flag these before. */
export function clientSupportsNotices(capabilities?: ClientCapabilities | null): boolean {
const notices = capabilities?.session?.notices;
return typeof notices === "object" && notices !== null && !Array.isArray(notices);
}
export function noticeUpdate(notice: SessionNotice): SessionNotification["update"] {
return {
sessionUpdate: "notice",
severity: notice.severity,
title: notice.title,
...(notice.description ? { description: notice.description } : {}),
};
}
/** The transcript rendering of a notice for clients without the capability. */
export function noticeTranscriptText(notice: SessionNotice): string {
return notice.description ? `**${notice.title}:** ${notice.description}` : `**${notice.title}**`;
}
/** The update to send for `notice`: a `notice` when the client can present
* one, else an agent message carrying `transcriptText` (by default the
* bold-label rendering of the notice) and `transcriptMeta`, so clients
* without the capability can still tell the line apart from Claude's reply. */
export function noticeOrTranscriptUpdate(
notice: SessionNotice,
supportsNotices: boolean,
transcriptText: string = noticeTranscriptText(notice),
transcriptMeta?: Record<string, unknown>,
): SessionNotification["update"] {
if (supportsNotices) return noticeUpdate(notice);
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: transcriptBlock(transcriptText) },
...(transcriptMeta ? { _meta: transcriptMeta } : {}),
};
}
/** Clients render consecutive agent_message_chunks as one markdown stream, so
* a bare `**Notice:** …` chunk lands mid-sentence: "…all done.**Notice:** Stop
* says: …". Put it in its own blockquote paragraph, blank lines on both sides,
* so it starts on a new line and Claude's next chunk does not continue the
* quote. */
export function transcriptBlock(text: string): string {
const lines = text.trim().split(/\r?\n/);
return `\n\n${lines.map((line) => (line ? `> ${line}` : ">")).join("\n")}\n\n`;
}
const HOOK_SPEAKER = /^[^\n:]{1,80} says: /;
/** Claude Code prefixes every line of a hook's `systemMessage` with
* "<HookEvent> says: ", so a two-line message reads "Stop says: a\nStop says:
* b". Keep the prefix on the first line only when every line repeats it. */
export function collapseHookSpeaker(text: string): string {
const lines = text.split(/\r?\n/);
const prefix = lines[0].match(HOOK_SPEAKER)?.[0];
if (!prefix || lines.length < 2 || !lines.every((line) => line.startsWith(prefix))) {
return text;
}
return [lines[0], ...lines.slice(1).map((line) => line.slice(prefix.length))].join("\n");
}
/** Fragments that read on after a bold label stand alone as a notice
* description, so start them with a capital. */
export function sentenceCase(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
/** Collapses whitespace so a text shown as a notice can be recognized when
* the SDK repeats it elsewhere (a hook-blocked turn's result, for one). */
export function normalizeNoticeText(text: string): string {
return text.trim().replace(/\s+/g, " ");
}
/** Notice fields are plain text. Free-form SDK/hook output may carry light
* markdown that a transcript would render; drop the inline markers rather
* than show literal asterisks and backticks in a toast. */
export function noticePlainText(text: string): string {
return text
.replace(/^[ \t]*#{1,6}[ \t]+/gm, "")
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
.replace(/(?<![\w`])`([^`\n]+)`(?![\w`])/g, "$1");
}
/** Shapes free-form text into a notice title plus optional description: the
* first line stands as the title when it is short enough, otherwise the whole
* text becomes the description under `fallbackTitle`. */
export function splitNoticeText(
text: string,
fallbackTitle: string,
): Pick<SessionNotice, "title" | "description"> {
const trimmed = noticePlainText(text).trim();
if (!trimmed) return { title: fallbackTitle };
const lineBreak = trimmed.search(/\r?\n/);
const firstLine = lineBreak === -1 ? trimmed : trimmed.slice(0, lineBreak).trimEnd();
const rest = lineBreak === -1 ? "" : trimmed.slice(lineBreak).trim();
if (firstLine.length > MAX_NOTICE_TITLE_LENGTH) {
return { title: fallbackTitle, description: trimmed };
}
return rest ? { title: firstLine, description: rest } : { title: firstLine };
}
/** The notice of Claude Code's `informational` message (e.g. why a Stop hook
* blocked continuation): a warning at level `warning`, else info. */
export function informationalNotice(message: { level: string; content: string }): SessionNotice {
const severity = message.level === "warning" ? "warning" : "info";
return {
severity,
...splitNoticeText(
message.content,
severity === "warning" ? "Claude reported a warning" : "Claude reported a notice",
),
};
}
/** The notice of a client MCP server that the `system/init` lists as not
* started: `failed`, or `needs-auth` when the client cannot run the OAuth
* flow (`oauthAvailable`). Undefined for any other status. */
export function mcpServerStartupNotice(
server: { name: string; status: string },
oauthAvailable: boolean,
): SessionNotice | undefined {
if (server.status === "failed") {
return {
severity: "warning",
title: `MCP server "${server.name}" failed to start`,
description: "Its tools are unavailable in this session. Run /mcp for the error.",
};
}
if (server.status === "needs-auth" && !oauthAvailable) {
return {
severity: "info",
title: `MCP server "${server.name}" needs authentication`,
description: "Its tools are unavailable until the server is authenticated.",
};
}
return undefined;
}
/**
* What Claude Code's `model_refusal_fallback` tells the user: the SDK retried
* a refused turn on the fallback model.
*
* Current CLIs only emit direction "retry", a swap that lasts for the
* session. "revert"/"sticky" are retained in the SDK enum for older CLIs,
* where "revert" marked a turn-only fallback: the session stays on the
* original model. `scope` (CLI 2.1.232+) "local" means a subagent,
* side-question or background fork response fell back and the session model
* is unchanged. Absent scope means an older CLI, where every retry was a
* session-level swap. `persistent` is whether the session now runs
* `fallback_model`, so its model option must follow.
*/
export function modelRefusalFallback(message: {
original_model: string;
fallback_model: string;
direction?: string;
scope?: string;
api_refusal_category?: string | null;
api_refusal_explanation?: string | null;
}): {
persistent: boolean;
/** The one-line summary. */
summary: string;
explanation: string | undefined;
/** The notice: the summary as its title when it is short enough, else a
* generic title with everything in the description. */
notice: SessionNotice;
/** The transcript line for a client without `notice` updates. */
transcript: string;
} {
const local = message.scope === "local";
const persistent = message.direction !== "revert" && !local;
const category = message.api_refusal_category ? ` (${message.api_refusal_category})` : "";
const outcome = persistent
? `The session will continue on ${message.fallback_model}.`
: local
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
: `The session stays on ${message.original_model}.`;
const summary =
`${message.original_model} declined this request${category}; ` +
`retried with ${message.fallback_model}. ${outcome}`;
const explanation = message.api_refusal_explanation || undefined;
const full = explanation ? `${summary}\n\n${explanation}` : summary;
return {
persistent,
summary,
explanation,
notice: {
severity: "warning",
...(summary.length > MAX_NOTICE_TITLE_LENGTH
? { title: "Model fallback", description: full }
: { title: summary, ...(explanation ? { description: explanation } : {}) }),
},
transcript: `**Model fallback:** ${full}`,
};
}