mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +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" },
|
||||
|
||||
Reference in New Issue
Block a user