mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-14 05:12:51 +00:00
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.
216 lines
8.9 KiB
TypeScript
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}`,
|
|
};
|
|
}
|