Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/browser/components/tools/BashToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,11 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
isFilteringLiveOutput && "opacity-60 blur-[1px]"
)}
>
{combinedLiveOutput.length > 0 ? combinedLiveOutput : "No output yet"}
{combinedLiveOutput.length > 0
? combinedLiveOutput
: status === "redacted"
? "Output excluded from shared transcript"
: "No output yet"}
Comment on lines +340 to +342

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render redaction notice in Bash details for stripped output

For shared transcripts built with includeToolOutput=false, bash parts now arrive with status: "redacted" and no output payload, but this placeholder is only rendered inside the live-output block. That block is gated by showLiveOutput (which only opens for executing/live-output cases), so the new "Output excluded from shared transcript" branch is unreachable in the normal redacted path and users get no explanation that output was intentionally removed.

Useful? React with 👍 / 👎.

</DetailContent>
{isFilteringLiveOutput && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
Expand Down
7 changes: 7 additions & 0 deletions src/browser/components/tools/GenericToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ export const GenericToolCall: React.FC<GenericToolCallProps> = ({
</DetailContent>
</DetailSection>
)}
{status === "redacted" && (
<DetailSection>
<DetailContent className="text-muted italic">
Output excluded from shared transcript
</DetailContent>
</DetailSection>
)}
</ToolDetails>
)}
</ToolContainer>
Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/tools/shared/ToolPrimitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ const getStatusColor = (status: string) => {
return "text-interrupted";
case "backgrounded":
return "text-backgrounded";
case "redacted":
return "text-foreground-secondary";
default:
return "text-foreground-secondary";
}
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/tools/shared/codeExecutionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ export interface NestedToolCall {
toolName: string;
input: unknown;
output?: unknown;
state: "input-available" | "output-available";
state: "input-available" | "output-available" | "output-redacted";
timestamp?: number;
}
15 changes: 12 additions & 3 deletions src/browser/components/tools/shared/toolUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { AlertTriangle, Check, CircleDot, X } from "lucide-react";
import { AlertTriangle, Check, CircleDot, EyeOff, X } from "lucide-react";
import type { ToolErrorResult } from "@/common/types/tools";
import { LoadingDots } from "./ToolPrimitives";

Expand All @@ -13,7 +13,8 @@ export type ToolStatus =
| "completed"
| "failed"
| "interrupted"
| "backgrounded";
| "backgrounded"
| "redacted";

/**
* Hook for managing tool expansion state
Expand Down Expand Up @@ -63,6 +64,13 @@ export function getStatusDisplay(status: ToolStatus): React.ReactNode {
<span className="status-text">backgrounded</span>
</>
);
case "redacted":
return (
<>
<EyeOff aria-hidden="true" className="mr-1 inline-block h-3 w-3 align-[-2px]" />
<span className="status-text">redacted</span>
</>
);
default:
return <span className="status-text">pending</span>;
}
Expand Down Expand Up @@ -123,12 +131,13 @@ export function isFailedToolOutput(output: unknown): boolean {
* - input-available + running → "executing"
*/
export function getNestedToolStatus(
state: "input-available" | "output-available",
state: "input-available" | "output-available" | "output-redacted",
output: unknown,
parentInterrupted: boolean
): ToolStatus {
if (state === "output-available") {
return isFailedToolOutput(output) ? "failed" : "completed";
}
if (state === "output-redacted") return "redacted";
return parentInterrupted ? "interrupted" : "executing";
}
4 changes: 3 additions & 1 deletion src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2082,10 +2082,12 @@ export class StreamingMessageAggregator {
});
} else if (isDynamicToolPart(part)) {
// Determine status based on part state and result
let status: "pending" | "executing" | "completed" | "failed" | "interrupted";
let status: "pending" | "executing" | "completed" | "failed" | "interrupted" | "redacted";
if (part.state === "output-available") {
// Check if result indicates failure (for tools that return { success: boolean })
status = hasFailureResult(part.output) ? "failed" : "completed";
} else if (part.state === "output-redacted") {
status = "redacted";
} else if (part.state === "input-available") {
// Most unfinished tool calls in partial messages represent an interruption.
// ask_user_question is different: it's intentionally waiting on user input,
Expand Down
1 change: 1 addition & 0 deletions src/common/orpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export {
BranchListResultSchema,
DynamicToolPartAvailableSchema,
DynamicToolPartPendingSchema,
DynamicToolPartRedactedSchema,
DynamicToolPartSchema,
FilePartSchema,
MuxFilePartSchema,
Expand Down
7 changes: 6 additions & 1 deletion src/common/orpc/schemas/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const NestedToolCallSchema = z.object({
toolName: z.string(),
input: z.unknown(),
output: z.unknown().optional(),
state: z.enum(["input-available", "output-available"]),
state: z.enum(["input-available", "output-available", "output-redacted"]),
timestamp: z.number().optional(),
});

Expand All @@ -62,10 +62,15 @@ export const DynamicToolPartAvailableSchema = MuxToolPartBase.extend({
output: z.unknown(),
nestedCalls: z.array(NestedToolCallSchema).optional(),
});
export const DynamicToolPartRedactedSchema = MuxToolPartBase.extend({
state: z.literal("output-redacted"),
nestedCalls: z.array(NestedToolCallSchema).optional(),
});

export const DynamicToolPartSchema = z.discriminatedUnion("state", [
DynamicToolPartAvailableSchema,
DynamicToolPartPendingSchema,
DynamicToolPartRedactedSchema,
]);

// Alias for message schemas
Expand Down
4 changes: 2 additions & 2 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ export type DisplayedMessage =
toolName: string;
args: unknown;
result?: unknown;
status: "pending" | "executing" | "completed" | "failed" | "interrupted";
status: "pending" | "executing" | "completed" | "failed" | "interrupted" | "redacted";
isPartial: boolean; // Whether the parent message was interrupted
historySequence: number; // Global ordering across all messages
streamSequence?: number; // Local ordering within this assistant message
Expand All @@ -533,7 +533,7 @@ export type DisplayedMessage =
toolName: string;
input: unknown;
output?: unknown;
state: "input-available" | "output-available";
state: "input-available" | "output-available" | "output-redacted";
timestamp?: number;
}>;
}
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/toolParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import type { z } from "zod";
import type {
DynamicToolPartAvailableSchema,
DynamicToolPartPendingSchema,
DynamicToolPartRedactedSchema,
DynamicToolPartSchema,
} from "../orpc/schemas";

export type DynamicToolPartAvailable = z.infer<typeof DynamicToolPartAvailableSchema>;
export type DynamicToolPartPending = z.infer<typeof DynamicToolPartPendingSchema>;
export type DynamicToolPartRedacted = z.infer<typeof DynamicToolPartRedactedSchema>;
export type DynamicToolPart = z.infer<typeof DynamicToolPartSchema>;

export function isDynamicToolPart(part: unknown): part is DynamicToolPart {
Expand Down
71 changes: 66 additions & 5 deletions src/common/utils/messages/transcriptShare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ function splitJsonlLines(jsonl: string): string[] {
}

describe("buildChatJsonlForSharing", () => {
it("strips tool output and sets state back to input-available when includeToolOutput=false", () => {
it("strips tool output and sets state to output-redacted when includeToolOutput=false", () => {
const messages: MuxMessage[] = [
{
id: "assistant-1",
Expand Down Expand Up @@ -36,7 +36,7 @@ describe("buildChatJsonlForSharing", () => {
throw new Error("Expected tool part");
}

expect(part.state).toBe("input-available");
expect(part.state).toBe("output-redacted");
expect(part).not.toHaveProperty("output");

// Original messages should be unchanged (no mutation during stripping)
Expand All @@ -48,7 +48,7 @@ describe("buildChatJsonlForSharing", () => {
expect(originalPart).toHaveProperty("output");
});

it("strips nestedCalls output and sets nestedCalls state back to input-available when includeToolOutput=false", () => {
it("strips nestedCalls output and sets nestedCalls state to output-redacted when includeToolOutput=false", () => {
const messages: MuxMessage[] = [
{
id: "assistant-1",
Expand Down Expand Up @@ -83,7 +83,7 @@ describe("buildChatJsonlForSharing", () => {
}

expect(part.state).toBe("input-available");
expect(part.nestedCalls?.[0].state).toBe("input-available");
expect(part.nestedCalls?.[0].state).toBe("output-redacted");
expect(part.nestedCalls?.[0]).not.toHaveProperty("output");

// Original nested call should still include output
Expand Down Expand Up @@ -370,7 +370,7 @@ describe("buildChatJsonlForSharing", () => {
throw new Error("Expected tool part");
}

expect(strippedPart.state).toBe("input-available");
expect(strippedPart.state).toBe("output-redacted");
expect(strippedPart).not.toHaveProperty("output");

// Original messages should be unchanged (no mutation during injection/stripping)
Expand All @@ -394,6 +394,67 @@ describe("buildChatJsonlForSharing", () => {
expect(originalStrippedPart.state).toBe("output-available");
expect(originalStrippedPart).toHaveProperty("output");
});
it("preserves task tool outputs while stripping other tool outputs when includeToolOutput=false", () => {
const messages: MuxMessage[] = [
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolCallId: "tc-task",
toolName: "task",
state: "output-available",
input: { prompt: "Fix the bug", title: "Bug fix" },
output: {
status: "completed",
taskId: "task-123",
reportMarkdown: "## Report\n\nFixed the bug in foo.ts",
},
},
{
type: "dynamic-tool",
toolCallId: "tc-bash",
toolName: "bash",
state: "output-available",
input: { script: "echo hi" },
output: { success: true, output: "hi" },
},
],
},
];

const jsonl = buildChatJsonlForSharing(messages, {
includeToolOutput: false,
});

const parsed = JSON.parse(splitJsonlLines(jsonl)[0]) as MuxMessage;

// task output should be preserved
const taskPart = parsed.parts[0];
if (taskPart.type !== "dynamic-tool" || taskPart.state !== "output-available") {
throw new Error("Expected completed task tool part");
}

const taskOutput = taskPart.output;
if (taskOutput === null || typeof taskOutput !== "object") {
throw new Error("Expected task output object");
}

expect((taskOutput as Record<string, unknown>).reportMarkdown).toBe(
"## Report\n\nFixed the bug in foo.ts"
);

// bash output should be stripped
const strippedPart = parsed.parts[1];
if (strippedPart.type !== "dynamic-tool") {
throw new Error("Expected tool part");
}

expect(strippedPart.state).toBe("output-redacted");
expect(strippedPart).not.toHaveProperty("output");
});

it("does not overwrite propose_plan planContent when already present", () => {
const messages: MuxMessage[] = [
{
Expand Down
19 changes: 14 additions & 5 deletions src/common/utils/messages/transcriptShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,25 @@ function stripNestedToolCallOutput(call: NestedToolCall): NestedToolCall {
const { output: _output, ...rest } = call;
return {
...rest,
state: "input-available",
state: "output-redacted",
};
}

// Tools whose output is preserved even when stripping — their output IS the content
// (plan text, sub-agent reports) and can't be reconstructed from disk.
const PRESERVE_OUTPUT_TOOLS = new Set([
"propose_plan",
"task",
"task_await",
"task_list",
"task_terminate",
"task_apply_git_patch",
]);

function stripToolPartOutput(part: MuxToolPart): MuxToolPart {
const nestedCalls = part.nestedCalls?.map(stripNestedToolCallOutput);

// Keep propose_plan output even when stripping other tool outputs.
// Shared transcripts need the plan content to be portable (mux-md can't read plan files from disk).
if (part.toolName === "propose_plan") {
if (PRESERVE_OUTPUT_TOOLS.has(part.toolName)) {
return nestedCalls ? { ...part, nestedCalls } : part;
}

Expand All @@ -128,7 +137,7 @@ function stripToolPartOutput(part: MuxToolPart): MuxToolPart {
const { output: _output, ...rest } = part;
return {
...rest,
state: "input-available",
state: "output-redacted",
nestedCalls,
};
}
Expand Down