mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-07 01:41:10 +00:00
Compare commits
5 Commits
v0.101.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b64c04de | ||
|
|
1ee2422934 | ||
|
|
7d1ef76c1d | ||
|
|
aa0c1234b5 | ||
|
|
54392e144d |
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]*$/,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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