Skip to content

Commit 2b59c0b

Browse files
OpenSource03claude
andcommitted
fix: Codex approval policies, terminal snapshot replay, and multi-hunk edit display
- Pass approvalPolicy and sandbox fields through to Codex thread/start and turn/start - Suppress terminal onData during snapshot replay to prevent escape-sequence echo - Fix multi-hunk single-file edits incorrectly counted as "multiple files" in compact summaries, diff rendering, and turn change tracking - Add unit tests for patch-utils and tool-formatting - Update claude-agent-sdk to 0.2.77 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f005ccd commit 2b59c0b

File tree

11 files changed

+164
-19
lines changed

11 files changed

+164
-19
lines changed

electron/src/ipc/codex-sessions.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ interface CodexSession {
4343
eventCounter: number;
4444
cwd: string;
4545
model?: string;
46+
/** Approval policy for the session — passed to turn/start and lazy thread/start */
47+
approvalPolicy?: string;
48+
/** Sandbox policy for the session — passed to lazy thread/start */
49+
sandbox?: string;
4650
}
4751

4852
import { SUPPORTED_SERVER_REQUESTS, isSupportedServerRequestMethod, pickModelId } from "@shared/lib/codex-helpers";
@@ -258,6 +262,8 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
258262
eventCounter: 0,
259263
cwd: options.cwd,
260264
model: undefined,
265+
approvalPolicy: options.approvalPolicy,
266+
sandbox: options.sandbox,
261267
};
262268
codexSessions.set(internalId, session);
263269
setupCodexHandlers(rpc, session, internalId, getMainWindow);
@@ -374,6 +380,8 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
374380
persistExtendedHistory: false,
375381
};
376382
if (session.model) threadParams.model = session.model;
383+
if (session.approvalPolicy) threadParams.approvalPolicy = session.approvalPolicy;
384+
if (session.sandbox) threadParams.sandbox = session.sandbox;
377385
const threadResult = await session.rpc.request<CodexThreadStartResponse>("thread/start", threadParams);
378386
session.threadId = threadResult.thread.id;
379387
log(
@@ -388,7 +396,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
388396

389397
log(
390398
"codex",
391-
` Send requested: session=${shortId(data.sessionId, 12)} thread=${shortId(session.threadId, 12)} text_len=${data.text.length} images=${data.images?.length ?? 0} effort=${data.effort ?? "default"} collab=${data.collaborationMode?.mode ?? "none"} activeTurn=${session.activeTurnId ? shortId(session.activeTurnId, 12) : "none"}`,
399+
` Send requested: session=${shortId(data.sessionId, 12)} thread=${shortId(session.threadId, 12)} text_len=${data.text.length} images=${data.images?.length ?? 0} effort=${data.effort ?? "default"} collab=${data.collaborationMode?.mode ?? "none"} approval=${session.approvalPolicy ?? "default"} activeTurn=${session.activeTurnId ? shortId(session.activeTurnId, 12) : "none"}`,
392400
);
393401

394402
try {
@@ -405,6 +413,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
405413
...(session.model ? { model: session.model } : {}),
406414
...(data.effort ? { effort: data.effort } : {}),
407415
...(data.collaborationMode ? { collaborationMode: data.collaborationMode } : {}),
416+
...(session.approvalPolicy ? { approvalPolicy: session.approvalPolicy } : {}),
408417
};
409418

410419

@@ -703,6 +712,8 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
703712
eventCounter: 0,
704713
cwd: data.cwd,
705714
model: data.model,
715+
approvalPolicy: data.approvalPolicy,
716+
sandbox: data.sandbox,
706717
};
707718
codexSessions.set(internalId, session);
708719
setupCodexHandlers(rpc, session, internalId, getMainWindow);

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "harnss",
3-
"version": "0.21.2",
3+
"version": "0.21.3",
44
"productName": "Harnss",
55
"description": "Harness your AI coding agents — one desktop app for Claude Code, Codex, and any ACP agent",
66
"author": {
@@ -31,7 +31,7 @@
3131
"packageManager": "pnpm@10.26.0",
3232
"dependencies": {
3333
"@agentclientprotocol/sdk": "^0.15.0",
34-
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
34+
"@anthropic-ai/claude-agent-sdk": "^0.2.77",
3535
"@huggingface/transformers": "^3.8.1",
3636
"@modelcontextprotocol/sdk": "^1.26.0",
3737
"@monaco-editor/react": "^4.7.0",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/ToolsPanel.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ function TerminalInstance({
229229
const lastSeqRef = useRef(0);
230230
const pendingChunksRef = useRef<Array<{ seq: number; data: string }>>([]);
231231
const hydratedRef = useRef(false);
232+
const suppressInputRef = useRef(false);
232233
const [ready, setReady] = useState(false);
233234

234235
// Initialize xterm
@@ -277,6 +278,7 @@ function TerminalInstance({
277278

278279
// Wire up input → PTY
279280
term.onData((data) => {
281+
if (suppressInputRef.current) return;
280282
window.claude.terminal.write(terminalId, data);
281283
});
282284

@@ -302,7 +304,16 @@ function TerminalInstance({
302304
if (disposed) return;
303305

304306
if (snapshot.output) {
305-
term.write(snapshot.output);
307+
// Restoring historical terminal output into a fresh xterm instance can
308+
// re-trigger terminal capability responses. Suppress onData while the
309+
// snapshot is replayed so old escape-sequence replies do not leak into
310+
// the live PTY as random input after a space switch.
311+
suppressInputRef.current = true;
312+
try {
313+
term.write(snapshot.output);
314+
} finally {
315+
suppressInputRef.current = false;
316+
}
306317
}
307318
lastSeqRef.current = snapshot.seq ?? 0;
308319
term.options.disableStdin = !!snapshot.exited;
@@ -336,6 +347,7 @@ function TerminalInstance({
336347
lastSeqRef.current = 0;
337348
pendingChunksRef.current = [];
338349
hydratedRef.current = false;
350+
suppressInputRef.current = false;
339351
};
340352
}, [terminalId]);
341353

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { UIMessage } from "@/types";
3+
import { formatCompactSummary } from "./tool-formatting";
4+
5+
describe("formatCompactSummary", () => {
6+
it("does not report Claude multi-hunk single-file edits as multiple files", () => {
7+
const message: UIMessage = {
8+
id: "edit-1",
9+
role: "tool_call",
10+
content: "",
11+
toolName: "Edit",
12+
toolInput: {
13+
file_path: "/repo/src/LtiTeacherAssignmentPreview.tsx",
14+
old_string: "old",
15+
new_string: "new",
16+
replace_all: false,
17+
},
18+
toolResult: {
19+
filePath: "/repo/src/LtiTeacherAssignmentPreview.tsx",
20+
oldString: "old",
21+
newString: "new",
22+
structuredPatch: [
23+
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ["-old", "+new"] },
24+
{ oldStart: 20, oldLines: 1, newStart: 20, newLines: 1, lines: ["-old2", "+new2"] },
25+
],
26+
},
27+
timestamp: 0,
28+
};
29+
30+
expect(formatCompactSummary(message)).toBe("LtiTeacherAssignmentPreview.tsx");
31+
});
32+
});

src/components/lib/tool-formatting.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { UIMessage, SubagentToolStep } from "@/types";
22
import { getMcpCompactSummary } from "@/components/McpToolContent";
33
import { getTodoItems } from "@/lib/todo-utils";
4-
import { getStructuredPatches } from "@/lib/patch-utils";
4+
import { getDistinctPatchPaths, getStructuredPatches } from "@/lib/patch-utils";
55

66
// ── Compact summary for collapsed tool line ──
77

@@ -65,8 +65,9 @@ export function formatCompactSummary(message: UIMessage): string {
6565
if (input.command) return String(input.command).split("\n")[0];
6666
// Multi-file Codex edits: show file count instead of single filename
6767
const patches = getStructuredPatches(result);
68-
if (input.file_path && patches.length > 1) {
69-
return `${patches.length} files`;
68+
const patchPaths = getDistinctPatchPaths(patches);
69+
if (input.file_path && patchPaths.length > 1) {
70+
return `${patchPaths.length} files`;
7071
}
7172
if (input.file_path) return String(input.file_path).split("/").pop() ?? "";
7273
if (filePathFromResult) return filePathFromResult.split("/").pop() ?? filePathFromResult;

src/components/tool-renderers/EditContent.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { parseUnifiedDiffFromUnknown } from "@/lib/unified-diff";
44
import { DiffViewer } from "@/components/DiffViewer";
55
import { UnifiedPatchViewer } from "@/components/UnifiedPatchViewer";
66
import { firstDefinedString } from "@/components/lib/tool-formatting";
7-
import { getStructuredPatches, getPatchPath, filterValidPatches, type StructuredPatchEntry } from "@/lib/patch-utils";
7+
import {
8+
getStructuredPatches,
9+
getPatchPath,
10+
filterValidPatches,
11+
isMultiFileStructuredPatch,
12+
type StructuredPatchEntry,
13+
} from "@/lib/patch-utils";
814
import { GenericContent } from "./GenericContent";
915

1016
// ── Multi-file rendering (Codex fileChange with N > 1 changes) ──
@@ -41,7 +47,7 @@ export function EditContent({ message }: { message: UIMessage }) {
4147
const structuredPatch = getStructuredPatches(message.toolResult);
4248

4349
// Multi-file Codex fileChange: render each file's diff separately
44-
if (structuredPatch.length > 1) {
50+
if (isMultiFileStructuredPatch(structuredPatch)) {
4551
const validPatches = filterValidPatches(structuredPatch);
4652
if (validPatches.length === 0) return <GenericContent message={message} />;
4753
return (

src/components/tool-renderers/WriteContent.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { useResolvedThemeClass } from "@/hooks/useResolvedThemeClass";
88
import { parseUnifiedDiff } from "@/lib/unified-diff";
99
import { UnifiedPatchViewer } from "@/components/UnifiedPatchViewer";
1010
import { OpenInEditorButton } from "@/components/OpenInEditorButton";
11-
import { getStructuredPatches, getPatchPath, filterValidPatches, type StructuredPatchEntry } from "@/lib/patch-utils";
11+
import {
12+
getStructuredPatches,
13+
getPatchPath,
14+
filterValidPatches,
15+
isMultiFileStructuredPatch,
16+
type StructuredPatchEntry,
17+
} from "@/lib/patch-utils";
1218
import { GenericContent } from "./GenericContent";
1319

1420
// ── Stable style constants (avoid re-creating on every render) ──
@@ -55,7 +61,7 @@ export function WriteContent({ message }: { message: UIMessage }) {
5561
const structuredPatch = getStructuredPatches(message.toolResult);
5662

5763
// Multi-file Codex fileChange: render each new file separately
58-
if (structuredPatch.length > 1) {
64+
if (isMultiFileStructuredPatch(structuredPatch)) {
5965
const validPatches = filterValidPatches(structuredPatch);
6066
if (validPatches.length === 0) return <GenericContent message={message} />;
6167
return (

src/lib/patch-utils.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
getDistinctPatchPaths,
4+
isMultiFileStructuredPatch,
5+
type StructuredPatchEntry,
6+
} from "./patch-utils";
7+
8+
describe("structured patch helpers", () => {
9+
it("treats Claude multi-hunk patches without file paths as single-file", () => {
10+
const patches: StructuredPatchEntry[] = [
11+
{
12+
oldStart: 1,
13+
oldLines: 2,
14+
newStart: 1,
15+
newLines: 2,
16+
lines: [" context", "-old", "+new"],
17+
},
18+
{
19+
oldStart: 20,
20+
oldLines: 2,
21+
newStart: 20,
22+
newLines: 2,
23+
lines: [" context", "-old2", "+new2"],
24+
},
25+
];
26+
27+
expect(getDistinctPatchPaths(patches)).toEqual([]);
28+
expect(isMultiFileStructuredPatch(patches)).toBe(false);
29+
});
30+
31+
it("treats multiple distinct patch file paths as multi-file", () => {
32+
const patches: StructuredPatchEntry[] = [
33+
{ filePath: "/repo/src/a.ts", diff: "diff --git a/src/a.ts b/src/a.ts" },
34+
{ filePath: "/repo/src/b.ts", diff: "diff --git a/src/b.ts b/src/b.ts" },
35+
];
36+
37+
expect(getDistinctPatchPaths(patches)).toEqual([
38+
"/repo/src/a.ts",
39+
"/repo/src/b.ts",
40+
]);
41+
expect(isMultiFileStructuredPatch(patches)).toBe(true);
42+
});
43+
});

src/lib/patch-utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface StructuredPatchEntry {
1919
diff?: string;
2020
oldString?: string;
2121
newString?: string;
22+
oldStart?: number;
23+
oldLines?: number;
24+
newStart?: number;
25+
newLines?: number;
26+
lines?: string[];
2227
}
2328

2429
// ── Extraction ──
@@ -43,3 +48,27 @@ export function filterValidPatches(
4348
): StructuredPatchEntry[] {
4449
return patches.filter((p) => getPatchPath(p) !== "");
4550
}
51+
52+
/** Return the distinct non-empty file paths represented in a structuredPatch array. */
53+
export function getDistinctPatchPaths(
54+
patches: StructuredPatchEntry[],
55+
): string[] {
56+
const seen = new Set<string>();
57+
const paths: string[] = [];
58+
59+
for (const patch of patches) {
60+
const patchPath = getPatchPath(patch);
61+
if (!patchPath || seen.has(patchPath)) continue;
62+
seen.add(patchPath);
63+
paths.push(patchPath);
64+
}
65+
66+
return paths;
67+
}
68+
69+
/** True only when structuredPatch entries clearly represent multiple files. */
70+
export function isMultiFileStructuredPatch(
71+
patches: StructuredPatchEntry[],
72+
): boolean {
73+
return getDistinctPatchPaths(patches).length > 1;
74+
}

0 commit comments

Comments
 (0)