Compare commits

...

5 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
Jonathan Ellis
1ee2422934 fix: keep autonomous turn tasks open across owed trailing idles (#1455)
When Claude Code starts a new autonomous cycle before the previous
cycle's trailing idle arrives, that late idle completes the new cycle's
AIR async task while Claude is still working. The next output then
creates a second task for the same cycle.

This change completes the autonomous task inside the existing cycle-end
branch, which already checks that `owedTrailingIdles` is zero. An owed
idle is now only consumed by the existing accounting and leaves the
current task open. The task still completes at its own terminal result
or at an unowed idle, so the task and the cycle end on the same
decision.

It adds a scripted SDK-stream regression for two consecutive autonomous
cycles with no running transition between them. The test checks that the
late idle leaves the second task open and that later output stays on the
same task, for a cycle that ends at a result and for one that ends at an
unowed idle. Both cases fail before the fix and pass after it.

Fixes #1453.

Validation, rebased on 0.98.0 (c90f856):
- `npm run build` and `npm run check` pass.
- `npm run test:run`: 3,378 passed, 32 skipped. One unrelated test,
`tools.test.ts` "picks up a rewrite that restored the modification time
of the file", failed once in the full run on a heavily loaded machine.
It passed 3 of 3 runs alone, both on this branch and on `main`, and this
change does not touch `tools.ts` or its tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:11:36 +00:00
Kerekes Dániel
7d1ef76c1d fix: report setup-token logins as logged in (#1376)
Fixes #1295

With a `claude setup-token` login (`CLAUDE_CODE_OAUTH_TOKEN`), `claude
auth status --json` only returns `{"loggedIn": true, "authMethod":
"oauth_token", "apiProvider": "firstParty"}`. `fromCliStatus()` handed
that to `mapAuthFields()`, which has no plan or key fields to go on and
fell back to "Not logged in". Clients then showed a working session as
logged out.

This maps that exact state to `kind: "account"` with the label suggested
in the issue ("Claude subscription", detail "OAuth token"). Everything
else still goes through `mapAuthFields()`.

Added a case to `auth-status.test.ts` that fails without the change.
`npm run check`, `npm run build` and `npm run test:run` pass.
2026-11-06 23:10:04 +01:00
Hussain Hammad
aa0c1234b5 fix: replay a compaction summary by its flag, not its framing (#1536)
Fixes #1535

A compaction summary on replay was only sent as a `compaction_update` if
its text also parsed, so a change in Claude Code's framing turned it
into a `user_message_chunk`, shown as text the user typed. That is what
happens to summaries from Claude Code 2.1.312 and 2.1.316.

`isCompactSummary` now decides on its own. A summary whose framing isn't
recognized is sent as a completed `compaction_update` without summary
text. This reverses the choice to keep such text visible: the
model-facing framing isn't kept as a summary either way, and showing it
as the user's words is the worse failure.

`PERSISTED_PREAMBLE` also allows content before the `Summary:` line, so
current summaries keep their text. All 31 summaries in my own
transcripts now parse, and a real `session/load` on one from 2.1.316
sends a `compaction_update` with its text and no summary
`user_message_chunk`.
2026-11-06 22:09:30 +00:00
Alexis Rouillard
54392e144d fix: relay a rate_limit_event that arrives before the turn's first assistant frame (#1534)
## Problem

`rate_limit_event` is relayed to the client as
`usage_update._meta["_claude/rateLimit"]`, but only when
`lastAssistantTotalUsage !== null`. With partial messages on, the SDK
emits `rate_limit_event` before the turn's first `assistant` message,
while `lastAssistantTotalUsage` is still null (reset at each turn). The
SDK emits it once per turn, so in practice clients never receive
`_claude/rateLimit`.

Observed with a real subscription session (SDK 0.3.316): the SDK sends
`stream_event`s, then `rate_limit_event`, then the first `assistant`.
The adapter dropped the event and sent only `usage_update`s with
`_claude/model`.

## Fix

When the event arrives before the turn has any usage, keep its
`rate_limit_info` and attach it to the next `usage_update`. That happens
in `attachUsageModel`, which every `usage_update` already goes through.
A rate limit that the update already carries wins over the pending one.
When usage is already known, the event is sent right away, as before.

## Tests

- New test in `acp-agent.test.ts`: a `rate_limit_event` before the first
assistant frame ends up on the turn's first `usage_update`, and on no
later one. It fails without the fix.
- `npm run check`, `npm run build` and `npm run test:run` pass.
- Checked by hand over stdio with a real session: the turn's first
`usage_update` now carries `_claude/rateLimit`, including
`unifiedWindows.seven_day`.
2026-11-06 22:07:33 +00:00
11 changed files with 282 additions and 33 deletions

View File

@@ -284,6 +284,7 @@ import {
import { IdleCompaction } from "./idle-compaction.js";
import {
clientSupportsNotices,
collapseHookSpeaker,
MAX_NOTICE_TITLE_LENGTH,
normalizeNoticeText,
noticeOrTranscriptUpdate,
@@ -4660,6 +4661,8 @@ export class ClaudeAcpAgent {
let lastAssistantTotalUsage: number | null = null;
let lastAssistantUsage: UsageSnapshot | null = null;
let lastAssistantModel: string | null = null;
// A rate_limit_event that came before the turn had any usage to report.
let pendingRateLimit: unknown = null;
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
// problem, billing), it sets a categorical `error` field on the
// `SDKAssistantMessage` that precedes the final `result` message. We capture
@@ -4967,7 +4970,8 @@ export class ClaudeAcpAgent {
};
// ACP's usage_update has no model field, so the model the usage belongs
// to rides in `_meta` alongside the other `_claude/*` keys.
// to rides in `_meta` alongside the other `_claude/*` keys, as does a
// pending rate limit.
const attachUsageModel = <
T extends {
sessionUpdate: "usage_update";
@@ -4978,6 +4982,13 @@ export class ClaudeAcpAgent {
>(
update: T,
): T => {
if (pendingRateLimit !== null) {
update = {
...update,
_meta: { "_claude/rateLimit": pendingRateLimit, ...(update._meta ?? {}) },
};
pendingRateLimit = null;
}
if (!lastAssistantModel) return update;
return {
...update,
@@ -6404,8 +6415,6 @@ export class ClaudeAcpAgent {
!firstUnsettledQueuedTurn()
) {
await asyncTasks.autonomousTurnStarted();
} else if (message.state === "idle" && asyncTasks.autonomousTurnOpen) {
await asyncTasks.autonomousTurnEnded("completed");
}
if (
message.state === "running" &&
@@ -6437,6 +6446,9 @@ export class ClaudeAcpAgent {
// here. An owed idle belongs to an earlier result: it can lag
// past the cycle's first output.
if (session.owedTrailingIdles === 0) {
if (asyncTasks.autonomousTurnOpen) {
await asyncTasks.autonomousTurnEnded("completed");
}
await closeCycleToolCalls();
this.endAutonomousCycle(params.sessionId, {
outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" },
@@ -6709,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(
@@ -8688,7 +8701,11 @@ export class ClaudeAcpAgent {
}
case "rate_limit_event": {
session.idleCompaction?.noteRateLimit(message.rate_limit_info);
if (lastAssistantTotalUsage !== null) {
// With partial messages on, the SDK sends this before the turn's
// first assistant frame, while there is no usage yet to send it with.
if (lastAssistantTotalUsage === null) {
pendingRateLimit = message.rate_limit_info;
} else {
await sendUpdate({
sessionId: params.sessionId,
update: attachUsageModel({
@@ -10037,9 +10054,10 @@ export class ClaudeAcpAgent {
// The entity is keyed by the summary message's uuid, which differs from
// the live id (the `compacting` status uuid) — replay is terminal-first
// by the RFD, and no client merges a replay into a live entity store.
// A subagent's own compaction summary is not the root session's, and a
// summary whose framing is unrecognized stays visible as transcript
// text rather than vanishing behind a summary-less entity.
// A subagent's own compaction summary is not the root session's. A summary
// whose framing is unrecognized is still this compaction, without summary
// text: isCompactSummary says what it is, and the framing is Claude Code's
// to change.
if (
replayCompactionUpdates &&
isCompactSummaryMessage(message) &&
@@ -10055,10 +10073,9 @@ export class ClaudeAcpAgent {
airClient: this.toolCallCapabilities.air.client,
},
);
if (replayCompaction.recordSummary(assistantMessageText(message.message))) {
await replayCompaction.finish(message.uuid, "completed");
return;
}
replayCompaction.recordSummary(assistantMessageText(message.message));
await replayCompaction.finish(message.uuid, "completed");
return;
}
for (const notification of toAcpNotifications(

View File

@@ -210,6 +210,15 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
return notLoggedInAuthStatus();
}
if (
status.loggedIn &&
status.authMethod === "oauth_token" &&
status.apiProvider === "firstParty" &&
!status.apiKeySource &&
!status.subscriptionType
) {
return { kind: "account", label: "Claude subscription", detail: "OAuth token" };
}
return mapAuthFields({
apiProvider: status.apiProvider,
subscriptionType: status.subscriptionType,

View File

@@ -67,7 +67,7 @@ const ANALYSIS_BLOCKS = [
* it as the post-compaction user message. */
const CONTINUATION_PREAMBLE = /^\s*This session is being continued from a previous conversation/i;
const PERSISTED_PREAMBLE =
/^\s*This session is being continued from a previous conversation[^\n]*\n+Summary:[ \t]*\r?\n/;
/^\s*This session is being continued from a previous conversation[^\n]*\n(?:[\s\S]*?\n)?Summary:[ \t]*\r?\n/;
const PERSISTED_TRAILERS = [
/\n+If you need specific details from before compaction[\s\S]*$/,
/\n+(?:Please )?[Cc]ontinue the conversation from where it left (?:it )?off[\s\S]*$/,

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

@@ -18610,8 +18610,8 @@ describe("assembled assistant text fallback", () => {
]);
expect(child.some((update) => update.sessionUpdate === "compaction_update")).toBe(false);
// Unrecognized framing: the text stays visible rather than vanishing
// behind a summary-less entity.
// Unrecognized framing: still the compaction isCompactSummary says it is,
// without summary text, and never a message from the user.
const unknownFraming = await replay(true, [
{
...summaryMessage("odd-summary"),
@@ -18622,12 +18622,14 @@ describe("assembled assistant text fallback", () => {
},
},
]);
expect(unknownFraming.some((update) => update.sessionUpdate === "compaction_update")).toBe(
false,
);
expect(unknownFraming.some((update) => update.sessionUpdate === "user_message_chunk")).toBe(
true,
);
expect(unknownFraming).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "odd-summary",
status: "completed",
_meta: { jetbrains: { air: { version: 1, contextCompaction: { version: 1 } } } },
},
]);
});
it("preserves the model response after multiple compactions in one turn", async () => {
@@ -18848,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" } },
});
@@ -18903,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: {} } });
@@ -25951,6 +25974,82 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
]);
});
// Issue #1453: a previous cycle's trailing idle completed the next turn task.
it.each(["result", "idle"] as const)(
"keeps the next turn task open across an owed idle until its own %s",
async (end) => {
const { agent, asyncTaskUpdates } = await airAgent();
let wake = () => {};
const woken = new Promise<void>((resolve) => (wake = resolve));
let finish = () => {};
const finished = new Promise<void>((resolve) => (finish = resolve));
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
yield userEcho((await iter.next()).value);
yield running();
yield resultMessage();
yield idle();
await woken;
yield running();
yield assistantText("first autonomous cycle");
yield resultMessage({ origin: { kind: "task-notification" } });
// The CLI stays running: the next output opens a new turn task.
yield assistantText("next autonomous cycle");
yield idle(); // The previous autonomous result's late trailer.
await finished;
yield assistantText("still working in the next cycle");
if (end === "result") {
yield resultMessage({ origin: { kind: "task-notification" } });
}
yield idle();
}
return messageGenerator();
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
wake();
try {
await waitFor(
() =>
asyncTaskUpdates().filter((u) => u.sessionUpdate === "async_task_spawned").length ===
2 && agent.sessions["test-session"].owedTrailingIdles === 0,
);
const [first, firstEnd, next] = asyncTaskUpdates();
expect(asyncTaskUpdates()).toEqual([
first,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: first.asyncTaskId,
state: "completed",
},
next,
]);
expect(firstEnd.state).toBe("completed");
expect(next.sessionUpdate).toBe("async_task_spawned");
expect(next.asyncTaskId).not.toBe(first.asyncTaskId);
} finally {
finish();
await agent.sessions["test-session"]?.consumer;
}
const [first, , next] = asyncTaskUpdates();
expect(asyncTaskUpdates()).toEqual([
first,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: first.asyncTaskId,
state: "completed",
},
next,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: next.asyncTaskId,
state: "completed",
},
]);
},
);
describe("started by a scheduler fire", () => {
const lifecycleStarted = (fire: { wakeup_source?: string; scheduled_for?: number }) =>
({
@@ -30206,6 +30305,61 @@ describe("agent selection config option", () => {
});
});
describe("rate_limit_event", () => {
it("rides on the next usage_update when it comes before the turn's first assistant frame", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => {
updates.push(notification);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
const rateLimit = { status: "allowed", resetsAt: 1700000000, rateLimitType: "seven_day" };
const usage = {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
injectGeneratorSession(agent, async function* (input) {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield {
type: "rate_limit_event",
rate_limit_info: rateLimit,
uuid: randomUUID(),
session_id: "test-session",
};
yield {
type: "assistant",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
message: {
id: "msg_rate",
role: "assistant",
model: "claude-sonnet-4-20250514",
content: [{ type: "text", text: "ok" }],
usage,
},
};
yield successfulResultMessage({ usage });
yield { type: "system", subtype: "session_state_changed", state: "idle" };
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
const usageMetas = updates
.map((notification) => notification.update)
.filter((update) => update.sessionUpdate === "usage_update")
.map((update) => update._meta?.["_claude/rateLimit"]);
expect(usageMetas[0]).toEqual(rateLimit);
expect(usageMetas.slice(1)).toEqual(usageMetas.slice(1).map(() => undefined));
});
});
describe("the SDK session id of a fresh Claude context", () => {
// After a clear-context restart the SDK runs under a new session id, and
// every SDK message carries it. The client knows only the ACP session id.

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

@@ -306,6 +306,14 @@ describe("auth status mappers", () => {
});
});
it("maps a setup-token CLI login without profile fields to an account", () => {
expect(
fromCliStatus(
JSON.stringify({ loggedIn: true, authMethod: "oauth_token", apiProvider: "firstParty" }),
),
).toEqual({ kind: "account", label: "Claude subscription", detail: "OAuth token" });
});
it("lets a third-party backend outrank the logged-out flag", () => {
// `loggedIn` tracks the claude.ai login only; on Bedrock the credentials
// are external and the agent is authenticated all the same.

View File

@@ -130,6 +130,17 @@ describe("compactionSummaryText", () => {
);
});
it("strips the framing when other content sits before the Summary line", () => {
expect(
compactionSummaryText(
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n" +
"<system-reminder>\n<tools-used-before-summary>Read, Bash</tools-used-before-summary>\n</system-reminder>\n" +
"Summary:\n1. Primary Request and Intent:\n Count upward.\n\n" +
"Continue the conversation from where it left off without asking the user any further questions.",
),
).toBe("1. Primary Request and Intent:\n Count upward.");
});
it("strips the older tagged transcript framing", () => {
expect(
compactionSummaryText(

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