mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 13:01:20 +00:00
fix: Session Replay and Permission rendering (#593)
Cleans up the replay of loaded sessions. Also makes always allow permissions clearer of what will be applied. Closes #579
This commit is contained in:
108
src/acp-agent.ts
108
src/acp-agent.ts
@@ -51,6 +51,7 @@ import {
|
||||
ModelUsage,
|
||||
Options,
|
||||
PermissionMode,
|
||||
PermissionUpdate,
|
||||
Query,
|
||||
query,
|
||||
Settings,
|
||||
@@ -305,6 +306,56 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
||||
// message and without invoking the model.
|
||||
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
|
||||
// their output as user messages in the session transcript, wrapping the
|
||||
// payload in these XML-like markers that the CLI uses for its own display.
|
||||
// The live prompt loop drops them; replay must strip them too or they leak
|
||||
// into the UI on session/load.
|
||||
const LOCAL_COMMAND_TAG_PATTERN =
|
||||
/<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
|
||||
|
||||
function stripMarkerTags(text: string): string {
|
||||
return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return user-message content with local-command marker tags removed, or
|
||||
* `null` if nothing meaningful remains (caller should skip the message).
|
||||
* Preserves real prose that's mixed in alongside the markers — e.g. a
|
||||
* message like `<command-name>…</command-name>hi` becomes `hi`.
|
||||
*/
|
||||
export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
if (typeof content === "string") {
|
||||
const stripped = stripMarkerTags(content);
|
||||
return stripped.trim() === "" ? null : stripped;
|
||||
}
|
||||
if (!Array.isArray(content)) return content;
|
||||
|
||||
const kept: unknown[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
(block as { type: unknown }).type === "text" &&
|
||||
"text" in block &&
|
||||
typeof (block as { text: unknown }).text === "string"
|
||||
) {
|
||||
const stripped = stripMarkerTags((block as { text: string }).text);
|
||||
if (stripped.trim() === "") continue;
|
||||
kept.push({ ...(block as object), text: stripped });
|
||||
} else {
|
||||
kept.push(block);
|
||||
}
|
||||
}
|
||||
if (kept.length === 0) return null;
|
||||
return kept;
|
||||
}
|
||||
|
||||
export function isLocalCommandMetadata(content: unknown): boolean {
|
||||
return stripLocalCommandMetadata(content) === null;
|
||||
}
|
||||
|
||||
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
@@ -343,6 +394,50 @@ export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the label for the "Always Allow" permission option so the user can see
|
||||
* the exact scope they are committing to. Uses the SDK-provided suggestions
|
||||
* when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
|
||||
* tool so "Always Allow" is never a blank check without disclosure.
|
||||
*/
|
||||
export function describeAlwaysAllow(
|
||||
suggestions: PermissionUpdate[] | undefined,
|
||||
toolName: string,
|
||||
): string {
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return `Always Allow all ${toolName}`;
|
||||
}
|
||||
|
||||
const ruleLabels: string[] = [];
|
||||
const directories: string[] = [];
|
||||
|
||||
for (const update of suggestions) {
|
||||
if (update.type === "addRules" && update.behavior === "allow") {
|
||||
for (const rule of update.rules) {
|
||||
ruleLabels.push(
|
||||
rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`,
|
||||
);
|
||||
}
|
||||
} else if (update.type === "addDirectories") {
|
||||
directories.push(...update.directories);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (ruleLabels.length > 0) {
|
||||
parts.push(ruleLabels.join(", "));
|
||||
}
|
||||
if (directories.length > 0) {
|
||||
parts.push(`access to ${directories.join(", ")}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return `Always Allow all ${toolName}`;
|
||||
}
|
||||
|
||||
return `Always Allow ${parts.join(" and ")}`;
|
||||
}
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
sessions: {
|
||||
@@ -1225,9 +1320,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
|
||||
for (const message of messages) {
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
let content: unknown = message.message.content;
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
if (message.message.role === "user") {
|
||||
content = stripLocalCommandMetadata(content);
|
||||
if (content === null) continue;
|
||||
}
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
message.message.content,
|
||||
content,
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
message.message.role,
|
||||
sessionId,
|
||||
@@ -1257,6 +1360,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
||||
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
||||
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
@@ -1347,7 +1451,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
name: alwaysAllowLabel,
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
|
||||
@@ -24,7 +24,15 @@ import {
|
||||
toolUpdateFromToolResult,
|
||||
toolUpdateFromEditToolResponse,
|
||||
} from "../tools.js";
|
||||
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
|
||||
import {
|
||||
toAcpNotifications,
|
||||
promptToClaude,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
ClaudeAcpAgent,
|
||||
claudeCliPath,
|
||||
describeAlwaysAllow,
|
||||
} from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -1110,6 +1118,113 @@ describe("toolUpdateFromEditToolResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLocalCommandMetadata", () => {
|
||||
it("returns null for strings that are pure marker metadata", () => {
|
||||
expect(stripLocalCommandMetadata("<command-name>/model</command-name>")).toBeNull();
|
||||
expect(
|
||||
stripLocalCommandMetadata("<local-command-stdout>out</local-command-stdout>"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
stripLocalCommandMetadata("<local-command-stderr>err</local-command-stderr>"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
stripLocalCommandMetadata(
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the string unchanged for real content", () => {
|
||||
expect(stripLocalCommandMetadata("hi")).toBe("hi");
|
||||
expect(stripLocalCommandMetadata("please run /model with args")).toBe(
|
||||
"please run /model with args",
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: in the original bug report the entire /model preamble and
|
||||
// the user's real "hi" prompt were concatenated into a single message.
|
||||
// We want to strip the marker tags and preserve the real prose, not drop
|
||||
// the whole message.
|
||||
it("strips marker tags from mixed-content strings, preserving real prose", () => {
|
||||
const mixed =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>" +
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>" +
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus[1m]</command-args>" +
|
||||
"<local-command-stdout>Set model to opus[1m] (claude-opus-4-7[1m])</local-command-stdout>" +
|
||||
"hi";
|
||||
const stripped = stripLocalCommandMetadata(mixed);
|
||||
expect(typeof stripped).toBe("string");
|
||||
expect(stripped as string).not.toContain("<command-name>");
|
||||
expect(stripped as string).not.toContain("<command-message>");
|
||||
expect(stripped as string).not.toContain("<command-args>");
|
||||
expect(stripped as string).not.toContain("<local-command-stdout>");
|
||||
expect((stripped as string).trimEnd()).toMatch(/hi$/);
|
||||
});
|
||||
|
||||
it("drops marker-only blocks from mixed arrays, keeping real blocks", () => {
|
||||
const result = stripLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
{ type: "text", text: "<local-command-stdout>ok</local-command-stdout>" },
|
||||
{ type: "text", text: "hi" },
|
||||
]);
|
||||
expect(result).toEqual([{ type: "text", text: "hi" }]);
|
||||
});
|
||||
|
||||
it("returns null when every block is a marker", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
{ type: "text", text: "<local-command-stdout>ok</local-command-stdout>" },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("strips tags inside a text block while keeping the trailing prose", () => {
|
||||
const result = stripLocalCommandMetadata([
|
||||
{
|
||||
type: "text",
|
||||
text: "<command-name>/model</command-name><local-command-stdout>ok</local-command-stdout>hi",
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual([{ type: "text", text: "hi" }]);
|
||||
});
|
||||
|
||||
it("leaves non-text blocks alone", () => {
|
||||
const image = { type: "image", source: { type: "base64", data: "", media_type: "image/png" } };
|
||||
const result = stripLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
image,
|
||||
]);
|
||||
expect(result).toEqual([image]);
|
||||
});
|
||||
|
||||
it("handles null/undefined/non-container shapes", () => {
|
||||
expect(stripLocalCommandMetadata(null)).toBeNull();
|
||||
expect(stripLocalCommandMetadata(undefined)).toBeUndefined();
|
||||
expect(stripLocalCommandMetadata({ arbitrary: "object" })).toEqual({ arbitrary: "object" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalCommandMetadata", () => {
|
||||
it("is true when stripping leaves nothing", () => {
|
||||
expect(isLocalCommandMetadata("<command-name>/model</command-name>")).toBe(true);
|
||||
expect(
|
||||
isLocalCommandMetadata([{ type: "text", text: "<command-name>/model</command-name>" }]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when real content survives stripping", () => {
|
||||
expect(isLocalCommandMetadata("hi")).toBe(false);
|
||||
expect(isLocalCommandMetadata("<command-name>/model</command-name>hi")).toBe(false);
|
||||
expect(
|
||||
isLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
{ type: "text", text: "hi" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape markdown", () => {
|
||||
it("should escape markdown characters", () => {
|
||||
let text = "Hello *world*!";
|
||||
@@ -1254,6 +1369,81 @@ describe("permission requests", () => {
|
||||
expect(Array.isArray(requestStructure.toolCall.content)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
describe("describeAlwaysAllow", () => {
|
||||
it("falls back to naming the whole tool when no suggestions are provided", () => {
|
||||
expect(describeAlwaysAllow(undefined, "Bash")).toBe("Always Allow all Bash");
|
||||
expect(describeAlwaysAllow([], "Read")).toBe("Always Allow all Read");
|
||||
});
|
||||
|
||||
it("includes the scoped rule content from a suggestion", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Bash", ruleContent: "npm test:*" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Bash",
|
||||
);
|
||||
expect(label).toBe("Always Allow Bash(npm test:*)");
|
||||
});
|
||||
|
||||
it("indicates a tool-wide rule when the suggestion has no ruleContent", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Read" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Read",
|
||||
);
|
||||
expect(label).toBe("Always Allow all Read");
|
||||
});
|
||||
|
||||
it("joins multiple rules and directory suggestions", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [
|
||||
{ toolName: "Bash", ruleContent: "git status" },
|
||||
{ toolName: "Bash", ruleContent: "git diff:*" },
|
||||
],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
{
|
||||
type: "addDirectories",
|
||||
directories: ["/tmp/work"],
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Bash",
|
||||
);
|
||||
expect(label).toBe("Always Allow Bash(git status), Bash(git diff:*) and access to /tmp/work");
|
||||
});
|
||||
|
||||
it("ignores non-allow rules and falls back when nothing is left", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Bash", ruleContent: "rm -rf:*" }],
|
||||
behavior: "deny",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Bash",
|
||||
);
|
||||
expect(label).toBe("Always Allow all Bash");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop reason propagation", () => {
|
||||
|
||||
@@ -145,4 +145,189 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
await agentB.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Regression test for https://github.com/zed-industries/claude-code-acp/issues/579
|
||||
// The client (Zed) renders its own local slash commands — e.g. `/model` —
|
||||
// by injecting user-message prompts whose text is wrapped in
|
||||
// `<command-name>` / `<local-command-stdout>` markers. The Claude SDK
|
||||
// persists those messages verbatim in the session transcript. Without
|
||||
// filtering, `loadSession` replays the markers as user_message_chunks
|
||||
// ahead of the real prompt, leaking CLI internals into the transcript.
|
||||
it("ACP: loadSession does not replay local-command metadata user messages", async () => {
|
||||
const recordedUserChunks: string[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
if (notification.update.sessionUpdate === "user_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") recordedUserChunks.push(content.text);
|
||||
}
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const commandName =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
|
||||
const commandStdout =
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>";
|
||||
|
||||
// Step 1: create a session via the SDK, push the marker user messages
|
||||
// followed by a real "hi" prompt. We bypass the ACP agent's prompt loop
|
||||
// so we're only testing what the loadSession replay path does, not how
|
||||
// prompts get routed.
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
await q.initializationResult();
|
||||
|
||||
// Zed renders its local slash commands by mixing the marker blocks into
|
||||
// the user's prompt content array alongside the real text. The Claude
|
||||
// SDK persists the message verbatim — including the marker blocks —
|
||||
// which is what loadSession must filter when it replays.
|
||||
input.push({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: commandName },
|
||||
{ type: "text", text: commandStdout },
|
||||
{ type: "text", text: "hi" },
|
||||
],
|
||||
},
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
|
||||
for await (const msg of q) {
|
||||
if (msg.type === "result") break;
|
||||
}
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
// Sanity check: the SDK transcript contains the metadata we're filtering.
|
||||
const sdkMessages = await getSessionMessages(sessionId);
|
||||
const sdkTexts = sdkMessages
|
||||
.map((m) => {
|
||||
const c = (m as { message?: { content?: unknown } }).message?.content;
|
||||
if (typeof c === "string") return c;
|
||||
if (Array.isArray(c))
|
||||
return c
|
||||
.map((b) =>
|
||||
b && typeof b === "object" && "text" in b
|
||||
? String((b as { text: unknown }).text)
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
return "";
|
||||
})
|
||||
.join("\n");
|
||||
expect(sdkTexts).toContain("<command-name>");
|
||||
expect(sdkTexts).toContain("<local-command-stdout>");
|
||||
expect(sdkTexts).toContain("hi");
|
||||
|
||||
// Step 2: load the session through the ACP agent and confirm the markers
|
||||
// never reach the client as user_message_chunks, while the real "hi"
|
||||
// prompt does.
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
|
||||
for (const chunk of recordedUserChunks) {
|
||||
expect(chunk).not.toContain("<command-name>");
|
||||
expect(chunk).not.toContain("<command-message>");
|
||||
expect(chunk).not.toContain("<command-args>");
|
||||
expect(chunk).not.toContain("<local-command-stdout>");
|
||||
expect(chunk).not.toContain("<local-command-stderr>");
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
// Second regression: the original issue showed the markers and the real
|
||||
// "hi" prompt all concatenated into a single string ending with "hi".
|
||||
// loadSession must strip the marker tags in-place rather than dropping the
|
||||
// whole message.
|
||||
it("ACP: loadSession strips marker tags from a concatenated single-string prompt", async () => {
|
||||
const recordedUserChunks: string[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
if (notification.update.sessionUpdate === "user_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") recordedUserChunks.push(content.text);
|
||||
}
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
|
||||
const concatenated =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>" +
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>\n" +
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus[1m]</command-args>" +
|
||||
"<local-command-stdout>Set model to opus[1m] (claude-opus-4-7[1m])</local-command-stdout>" +
|
||||
"hi";
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
await q.initializationResult();
|
||||
|
||||
input.push({
|
||||
type: "user",
|
||||
message: { role: "user", content: concatenated },
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
for await (const msg of q) {
|
||||
if (msg.type === "result") break;
|
||||
}
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
|
||||
expect(recordedUserChunks.length).toBeGreaterThan(0);
|
||||
for (const chunk of recordedUserChunks) {
|
||||
expect(chunk).not.toContain("<command-name>");
|
||||
expect(chunk).not.toContain("<command-message>");
|
||||
expect(chunk).not.toContain("<command-args>");
|
||||
expect(chunk).not.toContain("<local-command-stdout>");
|
||||
expect(chunk).not.toContain("<local-command-stderr>");
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user