mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-08 02:11:38 +00:00
Compare commits
1 Commits
v0.101.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b64c04de |
@@ -284,6 +284,7 @@ import {
|
||||
import { IdleCompaction } from "./idle-compaction.js";
|
||||
import {
|
||||
clientSupportsNotices,
|
||||
collapseHookSpeaker,
|
||||
MAX_NOTICE_TITLE_LENGTH,
|
||||
normalizeNoticeText,
|
||||
noticeOrTranscriptUpdate,
|
||||
@@ -6720,10 +6721,11 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
const severity = message.level === "warning" ? "warning" : "info";
|
||||
const transcriptContent = collapseHookSpeaker(message.content);
|
||||
const transcriptText =
|
||||
message.level === "info"
|
||||
? message.content
|
||||
: `**${sentenceCase(message.level)}:** ${message.content}`;
|
||||
? transcriptContent
|
||||
: `**${sentenceCase(message.level)}:** ${transcriptContent}`;
|
||||
await sendUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: noticeOrTranscriptUpdate(
|
||||
|
||||
@@ -50,11 +50,35 @@ export function noticeOrTranscriptUpdate(
|
||||
if (supportsNotices) return noticeUpdate(notice);
|
||||
return {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: transcriptText },
|
||||
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 {
|
||||
|
||||
@@ -18850,7 +18850,7 @@ describe("assembled assistant text fallback", () => {
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
|
||||
|
||||
const chunks = updates.filter(({ update }) => update.sessionUpdate === "agent_message_chunk");
|
||||
expect(messageChunkTexts(updates)).toEqual(["**Warning:** hook says no"]);
|
||||
expect(messageChunkTexts(updates)).toEqual(["\n\n> **Warning:** hook says no\n\n"]);
|
||||
expect(chunks[0]?.update).toMatchObject({
|
||||
_meta: { claudeCode: { kind: "informational", level: "warning" } },
|
||||
});
|
||||
@@ -18905,12 +18905,33 @@ describe("assembled assistant text fallback", () => {
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
|
||||
|
||||
const chunks = updates.filter(({ update }) => update.sessionUpdate === "agent_message_chunk");
|
||||
expect(messageChunkTexts(updates)).toEqual(["plain hook detail"]);
|
||||
expect(messageChunkTexts(updates)).toEqual(["\n\n> plain hook detail\n\n"]);
|
||||
expect(chunks[0]?.update).toMatchObject({
|
||||
_meta: { claudeCode: { kind: "informational", level: "info" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a multi-line hook notice as one quote with a single speaker prefix", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "informational",
|
||||
content: "Stop says: token: 3 calls\nStop says: hand off now",
|
||||
level: "notice",
|
||||
session_id: "test-session",
|
||||
},
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
|
||||
|
||||
expect(messageChunkTexts(updates)).toEqual([
|
||||
"\n\n> **Notice:** Stop says: token: 3 calls\n> hand off now\n\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("delivers an informational frame as a notice and still owns the result text", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
await initializeClient(agent, { session: { notices: {} } });
|
||||
|
||||
@@ -168,7 +168,10 @@ describe("AsyncTaskRuntime", () => {
|
||||
expect(published[1]?.update).not.toHaveProperty("summary");
|
||||
expect(published.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "**Task stopped by user:** Build generated assets." },
|
||||
content: {
|
||||
type: "text",
|
||||
text: "\n\n> **Task stopped by user:** Build generated assets.\n\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,7 +237,7 @@ describe("AsyncTaskRuntime", () => {
|
||||
]);
|
||||
expect(published.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "**Task stopped by user:** npm run build." },
|
||||
content: { type: "text", text: "\n\n> **Task stopped by user:** npm run build.\n\n" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,7 +267,7 @@ describe("AsyncTaskRuntime", () => {
|
||||
});
|
||||
expect(published.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "**Task stopped by user:** npm run build." },
|
||||
content: { type: "text", text: "\n\n> **Task stopped by user:** npm run build.\n\n" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "**Fast mode turned off:** requires extra usage to be enabled for this account.",
|
||||
text: "\n\n> **Fast mode turned off:** requires extra usage to be enabled for this account.\n\n",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("session mode", () => {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "**Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.",
|
||||
text: "\n\n> **Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.\n\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clientSupportsNotices,
|
||||
collapseHookSpeaker,
|
||||
MAX_NOTICE_TITLE_LENGTH,
|
||||
normalizeNoticeText,
|
||||
noticeOrTranscriptUpdate,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
noticeTranscriptText,
|
||||
sentenceCase,
|
||||
splitNoticeText,
|
||||
transcriptBlock,
|
||||
} from "../session-notices.js";
|
||||
|
||||
describe("session notices", () => {
|
||||
@@ -30,21 +32,42 @@ describe("session notices", () => {
|
||||
expect(noticeTranscriptText({ severity: "info", title: "Done" })).toBe("**Done**");
|
||||
expect(noticeOrTranscriptUpdate(notice, false)).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "**Fast mode turned off:** Why." },
|
||||
content: { type: "text", text: "\n\n> **Fast mode turned off:** Why.\n\n" },
|
||||
});
|
||||
expect(noticeOrTranscriptUpdate(notice, false, "custom line")).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "custom line" },
|
||||
content: { type: "text", text: "\n\n> custom line\n\n" },
|
||||
});
|
||||
const meta = { claudeCode: { kind: "informational", level: "warning" } };
|
||||
expect(noticeOrTranscriptUpdate(notice, false, "custom line", meta)).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "custom line" },
|
||||
content: { type: "text", text: "\n\n> custom line\n\n" },
|
||||
_meta: meta,
|
||||
});
|
||||
expect(noticeOrTranscriptUpdate(notice, true, "custom line", meta)).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("puts a transcript notice in its own blockquote paragraph", () => {
|
||||
// Without blank lines the chunk renders glued to Claude's last sentence:
|
||||
// "…all done.**Notice:** Stop says: …".
|
||||
expect(transcriptBlock("**Notice:** one line")).toBe("\n\n> **Notice:** one line\n\n");
|
||||
expect(transcriptBlock("**Notice:** first\nsecond\n\nfourth\n")).toBe(
|
||||
"\n\n> **Notice:** first\n> second\n>\n> fourth\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a hook's repeated speaker prefix on the first line only", () => {
|
||||
expect(collapseHookSpeaker("Stop says: token: 3 calls\nStop says: hand off now")).toBe(
|
||||
"Stop says: token: 3 calls\nhand off now",
|
||||
);
|
||||
expect(collapseHookSpeaker("Stop says: one line")).toBe("Stop says: one line");
|
||||
// Left alone unless every line repeats the same prefix.
|
||||
expect(collapseHookSpeaker("Stop says: a\nsomething else")).toBe(
|
||||
"Stop says: a\nsomething else",
|
||||
);
|
||||
expect(collapseHookSpeaker("no prefix\nStop says: b")).toBe("no prefix\nStop says: b");
|
||||
});
|
||||
|
||||
it("sends a notice update when the client can present one", () => {
|
||||
expect(
|
||||
noticeOrTranscriptUpdate({ severity: "info", title: "Task stopped by user" }, true),
|
||||
|
||||
Reference in New Issue
Block a user