Compare commits

...

1 Commits

Author SHA1 Message Date
alexwan
47b64c04de fix: start transcript notices on their own line for clients without session.notices (#1362)
## What

When a client does not advertise `session.notices`, a notice falls back
to an `agent_message_chunk` like `**Notice:** …`. Clients render
consecutive chunks as one markdown stream, so the fallback is glued to
Claude's last sentence. In Zed 1.24 a Stop hook's `systemMessage` shows
up as:

```
…推上去。**Notice:** Stop says: token: 3 calls
Stop says: hand off now
```

The fallback is now its own blockquote paragraph, with blank lines on
both sides:

```
…推上去。

> **Notice:** Stop says: token: 3 calls
> hand off now
```

## Behavior

- `noticeOrTranscriptUpdate` wraps the transcript text with
`transcriptBlock`: a leading blank line so it starts on a new line, `> `
on every line, and a trailing blank line so Claude's next chunk does not
continue the quote. This covers every transcript fallback (hook notices,
"Task stopped by user", "Fast mode turned off", "Auto mode
unavailable").
- Claude Code prefixes every line of a hook's `systemMessage` with
`<HookEvent> says: `. The informational fallback keeps that prefix on
the first line only, and only when every line repeats the same prefix
(`collapseHookSpeaker`).
- Clients that advertise `session.notices` get the same `notice` update
as before.
- `Turn.noticeTexts` still records `message.content` unchanged, so the
hook-blocked result de-duplication keeps working.

## Tests

- New unit tests for `transcriptBlock` and `collapseHookSpeaker`, plus
an end-to-end test for a two-line `notice`-level hook message.
- Updated the existing transcript fallback assertions to the blockquote
form.
- `npm run check`, `npm run build` and `npm run test:run` pass (3111
tests).

Co-authored-by: alex <hongchang.wan@longbridge-inc.com>
2026-11-06 22:14:44 +00:00
7 changed files with 86 additions and 13 deletions

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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: {} } });

View File

@@ -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" },
});
});

View File

@@ -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" });

View File

@@ -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",
},
});
});

View File

@@ -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),