@stigmer/ink 3.34.0 → 3.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/SessionApp.d.ts.map +1 -1
- package/app/SessionApp.js.map +1 -1
- package/app/SessionView.d.ts.map +1 -1
- package/app/SessionView.js.map +1 -1
- package/cli/stigmer-ink.d.ts.map +1 -1
- package/cli/stigmer-ink.js.map +1 -1
- package/components/AgentPicker.d.ts.map +1 -1
- package/components/AgentPicker.js.map +1 -1
- package/components/ApprovalPrompt.d.ts.map +1 -1
- package/components/ApprovalPrompt.js.map +1 -1
- package/components/ContextGauge.d.ts.map +1 -1
- package/components/ContextGauge.js.map +1 -1
- package/components/ExecutionProgress.d.ts.map +1 -1
- package/components/ExecutionProgress.js.map +1 -1
- package/components/FileChangeProgressBar.d.ts.map +1 -1
- package/components/FileChangeProgressBar.js.map +1 -1
- package/components/FileDiffBody.d.ts.map +1 -1
- package/components/FileDiffBody.js.map +1 -1
- package/components/FileReviewAtoms.d.ts.map +1 -1
- package/components/FileReviewAtoms.js.map +1 -1
- package/components/FileReviewPrompt.d.ts.map +1 -1
- package/components/FileReviewPrompt.js.map +1 -1
- package/components/FileReviewRecord.d.ts.map +1 -1
- package/components/FileReviewRecord.js.map +1 -1
- package/components/FollowUpInput.d.ts.map +1 -1
- package/components/FollowUpInput.js.map +1 -1
- package/components/MessageEntry.d.ts.map +1 -1
- package/components/MessageEntry.js.map +1 -1
- package/components/MessageThread.d.ts.map +1 -1
- package/components/MessageThread.js.map +1 -1
- package/components/ResourcePicker.d.ts.map +1 -1
- package/components/ResourcePicker.js.map +1 -1
- package/components/SessionPicker.d.ts.map +1 -1
- package/components/SessionPicker.js.map +1 -1
- package/components/SubAgentBlock.d.ts.map +1 -1
- package/components/SubAgentBlock.js.map +1 -1
- package/components/TodoList.d.ts.map +1 -1
- package/components/TodoList.js.map +1 -1
- package/components/ToolCallGroup.d.ts.map +1 -1
- package/components/ToolCallGroup.js.map +1 -1
- package/components/ToolCallItem.d.ts.map +1 -1
- package/components/ToolCallItem.js.map +1 -1
- package/components/UsageWidget.d.ts.map +1 -1
- package/components/UsageWidget.js.map +1 -1
- package/file-review.d.ts.map +1 -1
- package/file-review.js.map +1 -1
- package/index.d.ts.map +1 -1
- package/index.js.map +1 -1
- package/markdown.d.ts.map +1 -1
- package/markdown.js.map +1 -1
- package/package.json +4 -4
- package/provider.d.ts.map +1 -1
- package/provider.js.map +1 -1
- package/src/__tests__/components.test.tsx +0 -378
- package/src/__tests__/context-gauge.test.tsx +0 -176
- package/src/__tests__/data-pickers.test.tsx +0 -147
- package/src/__tests__/file-change-progress.test.tsx +0 -295
- package/src/__tests__/file-review.test.tsx +0 -811
- package/src/__tests__/markdown.test.ts +0 -46
- package/src/__tests__/resource-picker.test.tsx +0 -176
- package/src/__tests__/test-support.tsx +0 -31
|
@@ -1,811 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
3
|
-
import { render } from "ink-testing-library";
|
|
4
|
-
import { create } from "@bufbuild/protobuf";
|
|
5
|
-
import { FileReviewContext } from "@stigmer/react";
|
|
6
|
-
import {
|
|
7
|
-
ToolCallSchema,
|
|
8
|
-
FileContentSchema,
|
|
9
|
-
ToolCallOutputRefSchema,
|
|
10
|
-
AgentMessageSchema,
|
|
11
|
-
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
12
|
-
import type { FileContent } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
13
|
-
import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
14
|
-
import {
|
|
15
|
-
FileChangeSetSchema,
|
|
16
|
-
CapturedFileChangeSchema,
|
|
17
|
-
FileDecisionSchema,
|
|
18
|
-
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
19
|
-
import type { FileChangeSet } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
20
|
-
import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
21
|
-
import {
|
|
22
|
-
FileChangeKind,
|
|
23
|
-
FileChangeSetStatus,
|
|
24
|
-
FileDecisionAction,
|
|
25
|
-
FileDecisionScope,
|
|
26
|
-
FileReviewBlockReason,
|
|
27
|
-
DiffCompleteness,
|
|
28
|
-
MessageType,
|
|
29
|
-
ToolCallStatus,
|
|
30
|
-
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
31
|
-
import { ToolCallItem } from "../components/ToolCallItem.js";
|
|
32
|
-
import { ToolCallGroup } from "../components/ToolCallGroup.js";
|
|
33
|
-
import { FileReviewPrompt } from "../components/FileReviewPrompt.js";
|
|
34
|
-
import { FileReviewRecord } from "../components/FileReviewRecord.js";
|
|
35
|
-
import { FileDiffBody } from "../components/FileDiffBody.js";
|
|
36
|
-
import { MessageThread } from "../components/MessageThread.js";
|
|
37
|
-
import { FileLineStats } from "../components/FileReviewAtoms.js";
|
|
38
|
-
import { changeSetLineStats } from "../file-review.js";
|
|
39
|
-
import { fakeClient, renderWithClient } from "./test-support.js";
|
|
40
|
-
|
|
41
|
-
// --- fixtures -------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
interface ChangeOpts {
|
|
44
|
-
readonly id: string;
|
|
45
|
-
readonly path: string;
|
|
46
|
-
readonly kind?: FileChangeKind;
|
|
47
|
-
readonly diffComplete?: boolean;
|
|
48
|
-
readonly binary?: boolean;
|
|
49
|
-
readonly blockedReason?: FileReviewBlockReason;
|
|
50
|
-
readonly linesAdded?: number;
|
|
51
|
-
readonly linesRemoved?: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function makeChange(opts: ChangeOpts) {
|
|
55
|
-
return create(CapturedFileChangeSchema, {
|
|
56
|
-
id: opts.id,
|
|
57
|
-
pathAfter: opts.kind === FileChangeKind.DELETE ? "" : opts.path,
|
|
58
|
-
pathBefore: opts.kind === FileChangeKind.DELETE ? opts.path : "",
|
|
59
|
-
kind: opts.kind ?? FileChangeKind.MODIFY,
|
|
60
|
-
diffComplete: opts.diffComplete ?? true,
|
|
61
|
-
fileDigest: `fd-${opts.id}`,
|
|
62
|
-
blockedReason: opts.blockedReason ?? FileReviewBlockReason.UNSPECIFIED,
|
|
63
|
-
linesAdded: opts.linesAdded ?? 0,
|
|
64
|
-
linesRemoved: opts.linesRemoved ?? 0,
|
|
65
|
-
before: opts.binary ? create(FileContentSchema, { isBinary: true }) : undefined,
|
|
66
|
-
after: opts.binary ? create(FileContentSchema, { isBinary: true }) : undefined,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface SetOpts {
|
|
71
|
-
readonly id?: string;
|
|
72
|
-
readonly status: FileChangeSetStatus;
|
|
73
|
-
readonly changes: ReturnType<typeof makeChange>[];
|
|
74
|
-
readonly diffCompleteness?: DiffCompleteness;
|
|
75
|
-
readonly decisions?: {
|
|
76
|
-
readonly scope: FileDecisionScope;
|
|
77
|
-
readonly action: FileDecisionAction;
|
|
78
|
-
readonly fileChangeId?: string;
|
|
79
|
-
}[];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function makeSet(opts: SetOpts): FileChangeSet {
|
|
83
|
-
return create(FileChangeSetSchema, {
|
|
84
|
-
id: opts.id ?? "cs-1",
|
|
85
|
-
status: opts.status,
|
|
86
|
-
changes: opts.changes,
|
|
87
|
-
aggregateDigest: "agg-1",
|
|
88
|
-
diffCompleteness: opts.diffCompleteness ?? DiffCompleteness.COMPLETE,
|
|
89
|
-
decisions: (opts.decisions ?? []).map((d) =>
|
|
90
|
-
create(FileDecisionSchema, {
|
|
91
|
-
scope: d.scope,
|
|
92
|
-
action: d.action,
|
|
93
|
-
fileChangeId: d.fileChangeId ?? "",
|
|
94
|
-
}),
|
|
95
|
-
),
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function stampedTool(path: string, setId: string): ToolCall {
|
|
100
|
-
return create(ToolCallSchema, {
|
|
101
|
-
id: `tc-${path}`,
|
|
102
|
-
name: "write_file",
|
|
103
|
-
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
104
|
-
args: { path },
|
|
105
|
-
fileChangeSetId: setId,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function renderRow(toolCall: ToolCall, set: FileChangeSet | null) {
|
|
110
|
-
const changeSetsById = set ? new Map([[set.id, set]]) : new Map();
|
|
111
|
-
return render(
|
|
112
|
-
<FileReviewContext.Provider value={{ changeSetsById }}>
|
|
113
|
-
<ToolCallItem toolCall={toolCall} />
|
|
114
|
-
</FileReviewContext.Provider>,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// --- FileReviewPrompt: bulk ----------------------------------------------
|
|
119
|
-
|
|
120
|
-
describe("FileReviewPrompt — bulk", () => {
|
|
121
|
-
const completeSet = makeSet({
|
|
122
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
123
|
-
diffCompleteness: DiffCompleteness.COMPLETE,
|
|
124
|
-
changes: [
|
|
125
|
-
makeChange({ id: "a", path: "one.ts" }),
|
|
126
|
-
makeChange({ id: "b", path: "two.ts" }),
|
|
127
|
-
],
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("shows the summary and complete-set bulk actions", () => {
|
|
131
|
-
const { lastFrame } = render(
|
|
132
|
-
<FileReviewPrompt changeSet={completeSet} onSubmit={() => {}} />,
|
|
133
|
-
);
|
|
134
|
-
const out = lastFrame() ?? "";
|
|
135
|
-
expect(out).toContain("2 files awaiting review");
|
|
136
|
-
expect(out).toContain("[a] Approve all");
|
|
137
|
-
expect(out).toContain("[r] Reject all");
|
|
138
|
-
expect(out).toContain("[f] Review files");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("approves the whole set with the aggregate digest (no acknowledge)", () => {
|
|
142
|
-
const onSubmit = vi.fn();
|
|
143
|
-
const { stdin } = render(
|
|
144
|
-
<FileReviewPrompt changeSet={completeSet} onSubmit={onSubmit} />,
|
|
145
|
-
);
|
|
146
|
-
stdin.write("a");
|
|
147
|
-
expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
|
|
148
|
-
scope: FileDecisionScope.CHANGE_SET,
|
|
149
|
-
expectedDigest: "agg-1",
|
|
150
|
-
acknowledgeUnreviewable: false,
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("rejects the whole set", () => {
|
|
155
|
-
const onSubmit = vi.fn();
|
|
156
|
-
const { stdin } = render(
|
|
157
|
-
<FileReviewPrompt changeSet={completeSet} onSubmit={onSubmit} />,
|
|
158
|
-
);
|
|
159
|
-
stdin.write("r");
|
|
160
|
-
expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.REJECT, {
|
|
161
|
-
scope: FileDecisionScope.CHANGE_SET,
|
|
162
|
-
expectedDigest: "agg-1",
|
|
163
|
-
acknowledgeUnreviewable: false,
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("labels a binary-only set 'Keep all' and acknowledges on keep", () => {
|
|
168
|
-
const binarySet = makeSet({
|
|
169
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
170
|
-
diffCompleteness: DiffCompleteness.BINARY_SUMMARY_ONLY,
|
|
171
|
-
changes: [
|
|
172
|
-
makeChange({ id: "img", path: "logo.png", kind: FileChangeKind.BINARY_CHANGE, diffComplete: false, binary: true }),
|
|
173
|
-
makeChange({ id: "txt", path: "readme.md" }),
|
|
174
|
-
],
|
|
175
|
-
});
|
|
176
|
-
const onSubmit = vi.fn();
|
|
177
|
-
const { stdin, lastFrame } = render(
|
|
178
|
-
<FileReviewPrompt changeSet={binarySet} onSubmit={onSubmit} />,
|
|
179
|
-
);
|
|
180
|
-
expect(lastFrame() ?? "").toContain("[a] Keep all");
|
|
181
|
-
stdin.write("a");
|
|
182
|
-
expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
|
|
183
|
-
scope: FileDecisionScope.CHANGE_SET,
|
|
184
|
-
expectedDigest: "agg-1",
|
|
185
|
-
acknowledgeUnreviewable: true,
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("hides bulk keep for a blocked set (no [a]); reject + review only", () => {
|
|
190
|
-
const blockedSet = makeSet({
|
|
191
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
192
|
-
diffCompleteness: DiffCompleteness.PARTIAL_BLOCKED,
|
|
193
|
-
changes: [
|
|
194
|
-
makeChange({ id: "s", path: ".env", diffComplete: false, blockedReason: FileReviewBlockReason.SECRET_WITHHELD }),
|
|
195
|
-
makeChange({ id: "ok", path: "app.ts" }),
|
|
196
|
-
],
|
|
197
|
-
});
|
|
198
|
-
const onSubmit = vi.fn();
|
|
199
|
-
const { stdin, lastFrame } = render(
|
|
200
|
-
<FileReviewPrompt changeSet={blockedSet} onSubmit={onSubmit} />,
|
|
201
|
-
);
|
|
202
|
-
const out = lastFrame() ?? "";
|
|
203
|
-
expect(out).not.toContain("[a]");
|
|
204
|
-
expect(out).toContain("[r] Reject all");
|
|
205
|
-
expect(out).toContain("[f] Review files");
|
|
206
|
-
stdin.write("a"); // no bulk-approve option → ignored
|
|
207
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// --- FileReviewPrompt: per-file ------------------------------------------
|
|
212
|
-
|
|
213
|
-
/** Let ink/React flush a state transition between keystrokes. */
|
|
214
|
-
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
215
|
-
|
|
216
|
-
describe("FileReviewPrompt — per-file", () => {
|
|
217
|
-
const blockedSet = makeSet({
|
|
218
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
219
|
-
diffCompleteness: DiffCompleteness.PARTIAL_BLOCKED,
|
|
220
|
-
changes: [
|
|
221
|
-
makeChange({ id: "ok", path: "app.ts" }),
|
|
222
|
-
makeChange({ id: "s", path: ".env", diffComplete: false, blockedReason: FileReviewBlockReason.SECRET_WITHHELD }),
|
|
223
|
-
],
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it("enters per-file mode and keeps a reviewable file with its file digest", async () => {
|
|
227
|
-
const onSubmit = vi.fn();
|
|
228
|
-
// Per-file mode mounts FileDiffBody, whose useFileChangeContent reads the
|
|
229
|
-
// Stigmer client — so these tests render under a provider.
|
|
230
|
-
const { stdin, lastFrame } = renderWithClient(
|
|
231
|
-
<FileReviewPrompt changeSet={blockedSet} onSubmit={onSubmit} />,
|
|
232
|
-
);
|
|
233
|
-
stdin.write("f");
|
|
234
|
-
await tick();
|
|
235
|
-
const out = lastFrame() ?? "";
|
|
236
|
-
expect(out).toContain("app.ts");
|
|
237
|
-
expect(out).toContain(".env");
|
|
238
|
-
expect(out).toContain("keep"); // hint line
|
|
239
|
-
stdin.write("k"); // keep the selected (first) file
|
|
240
|
-
expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
|
|
241
|
-
scope: FileDecisionScope.FILE,
|
|
242
|
-
fileChangeId: "ok",
|
|
243
|
-
expectedDigest: "fd-ok",
|
|
244
|
-
acknowledgeUnreviewable: false,
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("refuses to keep an unavailable file but allows discard", async () => {
|
|
249
|
-
const onSubmit = vi.fn();
|
|
250
|
-
const { stdin } = renderWithClient(
|
|
251
|
-
<FileReviewPrompt changeSet={blockedSet} onSubmit={onSubmit} />,
|
|
252
|
-
);
|
|
253
|
-
stdin.write("f");
|
|
254
|
-
await tick();
|
|
255
|
-
stdin.write("\u001B[B"); // down → select the secret file
|
|
256
|
-
await tick();
|
|
257
|
-
stdin.write("k"); // keep refused for unavailable
|
|
258
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
259
|
-
stdin.write("d"); // discard allowed
|
|
260
|
-
expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.REJECT, {
|
|
261
|
-
scope: FileDecisionScope.FILE,
|
|
262
|
-
fileChangeId: "s",
|
|
263
|
-
expectedDigest: "fd-s",
|
|
264
|
-
acknowledgeUnreviewable: false,
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it("keeps a binary file with an acknowledgement", async () => {
|
|
269
|
-
const binarySet = makeSet({
|
|
270
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
271
|
-
diffCompleteness: DiffCompleteness.BINARY_SUMMARY_ONLY,
|
|
272
|
-
changes: [
|
|
273
|
-
makeChange({ id: "img", path: "logo.png", kind: FileChangeKind.BINARY_CHANGE, diffComplete: false, binary: true }),
|
|
274
|
-
],
|
|
275
|
-
});
|
|
276
|
-
const onSubmit = vi.fn();
|
|
277
|
-
const { stdin } = renderWithClient(
|
|
278
|
-
<FileReviewPrompt changeSet={binarySet} onSubmit={onSubmit} />,
|
|
279
|
-
);
|
|
280
|
-
stdin.write("f");
|
|
281
|
-
await tick();
|
|
282
|
-
stdin.write("k");
|
|
283
|
-
expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
|
|
284
|
-
scope: FileDecisionScope.FILE,
|
|
285
|
-
fileChangeId: "img",
|
|
286
|
-
expectedDigest: "fd-img",
|
|
287
|
-
acknowledgeUnreviewable: true,
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// --- FileReviewPrompt: state ---------------------------------------------
|
|
293
|
-
|
|
294
|
-
describe("FileReviewPrompt — state", () => {
|
|
295
|
-
const set = makeSet({
|
|
296
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
297
|
-
changes: [makeChange({ id: "a", path: "one.ts" })],
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it("shows submitting and ignores input while a decision is in flight", () => {
|
|
301
|
-
const onSubmit = vi.fn();
|
|
302
|
-
const { stdin, lastFrame } = render(
|
|
303
|
-
<FileReviewPrompt
|
|
304
|
-
changeSet={set}
|
|
305
|
-
onSubmit={onSubmit}
|
|
306
|
-
submittingDecisionKeys={new Set(["cs-1"])}
|
|
307
|
-
/>,
|
|
308
|
-
);
|
|
309
|
-
expect(lastFrame() ?? "").toContain("Submitting");
|
|
310
|
-
stdin.write("a");
|
|
311
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it("renders a decision error", () => {
|
|
315
|
-
const { lastFrame } = render(
|
|
316
|
-
<FileReviewPrompt
|
|
317
|
-
changeSet={set}
|
|
318
|
-
onSubmit={() => {}}
|
|
319
|
-
decisionErrors={new Map([["cs-1", new Error("digest mismatch")]])}
|
|
320
|
-
/>,
|
|
321
|
-
);
|
|
322
|
-
expect(lastFrame() ?? "").toContain("digest mismatch");
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it("is inert and hints when isActive is false", () => {
|
|
326
|
-
const onSubmit = vi.fn();
|
|
327
|
-
const { stdin, lastFrame } = render(
|
|
328
|
-
<FileReviewPrompt changeSet={set} onSubmit={onSubmit} isActive={false} />,
|
|
329
|
-
);
|
|
330
|
-
stdin.write("a");
|
|
331
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
332
|
-
expect(lastFrame() ?? "").toContain("Resolve the pending approval first");
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
// --- Row badges -----------------------------------------------------------
|
|
337
|
-
|
|
338
|
-
describe("ToolCallItem — file-review badge", () => {
|
|
339
|
-
it("badges a pending set as 'Pending review'", () => {
|
|
340
|
-
const set = makeSet({
|
|
341
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
342
|
-
changes: [makeChange({ id: "a", path: "notes.md" })],
|
|
343
|
-
});
|
|
344
|
-
const { lastFrame } = renderRow(stampedTool("notes.md", "cs-1"), set);
|
|
345
|
-
expect(lastFrame() ?? "").toContain("Pending review");
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
it("badges a kept file as 'Kept' and a discarded one as 'Discarded'", () => {
|
|
349
|
-
const kept = makeSet({
|
|
350
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
351
|
-
changes: [makeChange({ id: "a", path: "notes.md" })],
|
|
352
|
-
decisions: [{ scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "a" }],
|
|
353
|
-
});
|
|
354
|
-
expect(renderRow(stampedTool("notes.md", "cs-1"), kept).lastFrame() ?? "").toContain("Kept");
|
|
355
|
-
|
|
356
|
-
const discarded = makeSet({
|
|
357
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
358
|
-
changes: [makeChange({ id: "a", path: "notes.md" })],
|
|
359
|
-
decisions: [{ scope: FileDecisionScope.FILE, action: FileDecisionAction.REJECT, fileChangeId: "a" }],
|
|
360
|
-
});
|
|
361
|
-
expect(renderRow(stampedTool("notes.md", "cs-1"), discarded).lastFrame() ?? "").toContain("Discarded");
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it("badges a failed set as 'Review failed'", () => {
|
|
365
|
-
const failed = makeSet({
|
|
366
|
-
status: FileChangeSetStatus.FAILED,
|
|
367
|
-
changes: [makeChange({ id: "a", path: "notes.md" })],
|
|
368
|
-
});
|
|
369
|
-
expect(renderRow(stampedTool("notes.md", "cs-1"), failed).lastFrame() ?? "").toContain("Review failed");
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
it("shows no badge for an unstamped row", () => {
|
|
373
|
-
const tc = create(ToolCallSchema, {
|
|
374
|
-
id: "tc-1",
|
|
375
|
-
name: "write_file",
|
|
376
|
-
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
377
|
-
args: { path: "notes.md" },
|
|
378
|
-
});
|
|
379
|
-
const out = renderRow(tc, null).lastFrame() ?? "";
|
|
380
|
-
expect(out).not.toContain("Pending review");
|
|
381
|
-
expect(out).not.toContain("Kept");
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it("shows no badge when the set is still CAPTURING", () => {
|
|
385
|
-
const capturing = makeSet({
|
|
386
|
-
status: FileChangeSetStatus.CAPTURING,
|
|
387
|
-
changes: [makeChange({ id: "a", path: "notes.md" })],
|
|
388
|
-
});
|
|
389
|
-
const out = renderRow(stampedTool("notes.md", "cs-1"), capturing).lastFrame() ?? "";
|
|
390
|
-
expect(out).not.toContain("Pending review");
|
|
391
|
-
expect(out).not.toContain("Kept");
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
it("shows no badge when the stamped path is absent from the set", () => {
|
|
395
|
-
const decided = makeSet({
|
|
396
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
397
|
-
changes: [makeChange({ id: "a", path: "other.md" })],
|
|
398
|
-
decisions: [{ scope: FileDecisionScope.CHANGE_SET, action: FileDecisionAction.APPROVE }],
|
|
399
|
-
});
|
|
400
|
-
const out = renderRow(stampedTool("notes.md", "cs-1"), decided).lastFrame() ?? "";
|
|
401
|
-
expect(out).not.toContain("Kept");
|
|
402
|
-
});
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
// --- Collapsed-group aggregate cue ---------------------------------------
|
|
406
|
-
|
|
407
|
-
describe("ToolCallGroup — aggregate review cue", () => {
|
|
408
|
-
it("surfaces a pending edit on the collapsed summary", () => {
|
|
409
|
-
const set = makeSet({
|
|
410
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
411
|
-
changes: [makeChange({ id: "a", path: "notes.md" })],
|
|
412
|
-
});
|
|
413
|
-
const { lastFrame } = render(
|
|
414
|
-
<FileReviewContext.Provider value={{ changeSetsById: new Map([[set.id, set]]) }}>
|
|
415
|
-
<ToolCallGroup toolCalls={[stampedTool("notes.md", "cs-1")]} />
|
|
416
|
-
</FileReviewContext.Provider>,
|
|
417
|
-
);
|
|
418
|
-
expect(lastFrame() ?? "").toContain("1 pending review");
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it("shows no cue when no row is stamped", () => {
|
|
422
|
-
const tc = create(ToolCallSchema, { id: "t", name: "read_file", status: ToolCallStatus.TOOL_CALL_COMPLETED });
|
|
423
|
-
const { lastFrame } = render(<ToolCallGroup toolCalls={[tc]} />);
|
|
424
|
-
const out = lastFrame() ?? "";
|
|
425
|
-
expect(out).not.toContain("pending review");
|
|
426
|
-
});
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
// --- Settled record -------------------------------------------------------
|
|
430
|
-
|
|
431
|
-
describe("FileReviewRecord", () => {
|
|
432
|
-
it("summarizes kept/discarded and lists files", () => {
|
|
433
|
-
const set = makeSet({
|
|
434
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
435
|
-
changes: [
|
|
436
|
-
makeChange({ id: "a", path: "one.ts" }),
|
|
437
|
-
makeChange({ id: "b", path: "two.ts" }),
|
|
438
|
-
makeChange({ id: "c", path: "three.ts" }),
|
|
439
|
-
],
|
|
440
|
-
decisions: [
|
|
441
|
-
{ scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "a" },
|
|
442
|
-
{ scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "b" },
|
|
443
|
-
{ scope: FileDecisionScope.FILE, action: FileDecisionAction.REJECT, fileChangeId: "c" },
|
|
444
|
-
],
|
|
445
|
-
});
|
|
446
|
-
const { lastFrame } = render(<FileReviewRecord fileChangeSet={set} />);
|
|
447
|
-
const out = lastFrame() ?? "";
|
|
448
|
-
expect(out).toContain("2 kept · 1 discarded");
|
|
449
|
-
expect(out).toContain("one.ts");
|
|
450
|
-
expect(out).toContain("(discarded)");
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
it("reports a failed reconcile", () => {
|
|
454
|
-
const set = makeSet({
|
|
455
|
-
status: FileChangeSetStatus.FAILED,
|
|
456
|
-
changes: [makeChange({ id: "a", path: "one.ts" })],
|
|
457
|
-
});
|
|
458
|
-
expect(render(<FileReviewRecord fileChangeSet={set} />).lastFrame() ?? "").toContain("review failed");
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
// --- MessageThread integration -------------------------------------------
|
|
463
|
-
|
|
464
|
-
describe("MessageThread — file-review integration", () => {
|
|
465
|
-
function execWithStampedEdit(status: FileChangeSetStatus, decided: boolean) {
|
|
466
|
-
const set = makeSet({
|
|
467
|
-
id: "cs-1",
|
|
468
|
-
status,
|
|
469
|
-
changes: [makeChange({ id: "a", path: "notes.md" })],
|
|
470
|
-
decisions: decided
|
|
471
|
-
? [{ scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "a" }]
|
|
472
|
-
: [],
|
|
473
|
-
});
|
|
474
|
-
const aiMsg = create(AgentMessageSchema, {
|
|
475
|
-
type: MessageType.MESSAGE_AI,
|
|
476
|
-
content: "Editing notes",
|
|
477
|
-
toolCalls: [stampedTool("notes.md", "cs-1")],
|
|
478
|
-
});
|
|
479
|
-
return create(AgentExecutionSchema, {
|
|
480
|
-
metadata: { id: "aex-1" },
|
|
481
|
-
status: {
|
|
482
|
-
phase: undefined,
|
|
483
|
-
messages: [aiMsg],
|
|
484
|
-
fileChangeSets: [set],
|
|
485
|
-
},
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
it("badges a settled row and appends a settled record for a completed execution", () => {
|
|
490
|
-
const exec = execWithStampedEdit(FileChangeSetStatus.RECONCILED, true);
|
|
491
|
-
const { lastFrame } = render(
|
|
492
|
-
<MessageThread executions={[exec]} showFileReviewRecords expandToolCalls />,
|
|
493
|
-
);
|
|
494
|
-
const out = lastFrame() ?? "";
|
|
495
|
-
expect(out).toContain("Kept"); // per-row badge
|
|
496
|
-
expect(out).toContain("File review — 1 kept"); // settled record
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("does not render a settled record for an AWAITING set (that is the prompt's job)", () => {
|
|
500
|
-
const exec = execWithStampedEdit(FileChangeSetStatus.AWAITING_REVIEW, false);
|
|
501
|
-
const { lastFrame } = render(
|
|
502
|
-
<MessageThread executions={[]} activeStreamExecution={exec} showFileReviewRecords expandToolCalls />,
|
|
503
|
-
);
|
|
504
|
-
const out = lastFrame() ?? "";
|
|
505
|
-
expect(out).toContain("Pending review"); // the row still badges
|
|
506
|
-
expect(out).not.toContain("File review —"); // but no settled record
|
|
507
|
-
});
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
// --- FileLineStats (shared atom) -----------------------------------------
|
|
511
|
-
|
|
512
|
-
describe("FileLineStats", () => {
|
|
513
|
-
it("renders nothing when both counts are zero (never +0 -0)", () => {
|
|
514
|
-
const { lastFrame } = render(<FileLineStats linesAdded={0} linesRemoved={0} />);
|
|
515
|
-
expect((lastFrame() ?? "").trim()).toBe("");
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
it("renders +N -M when counts exist", () => {
|
|
519
|
-
const { lastFrame } = render(<FileLineStats linesAdded={7} linesRemoved={3} />);
|
|
520
|
-
const out = lastFrame() ?? "";
|
|
521
|
-
expect(out).toContain("+7");
|
|
522
|
-
expect(out).toContain("-3");
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
it("renders a one-sided stat (added only) without hiding it", () => {
|
|
526
|
-
const { lastFrame } = render(<FileLineStats linesAdded={5} linesRemoved={0} />);
|
|
527
|
-
const out = lastFrame() ?? "";
|
|
528
|
-
expect(out).toContain("+5");
|
|
529
|
-
expect(out).toContain("-0"); // both sides always shown once any count exists
|
|
530
|
-
});
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
// --- changeSetLineStats (pure helper) ------------------------------------
|
|
534
|
-
|
|
535
|
-
describe("changeSetLineStats", () => {
|
|
536
|
-
it("sums per-file counts across the set", () => {
|
|
537
|
-
const set = makeSet({
|
|
538
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
539
|
-
changes: [
|
|
540
|
-
makeChange({ id: "a", path: "one.ts", linesAdded: 10, linesRemoved: 2 }),
|
|
541
|
-
makeChange({ id: "b", path: "two.ts", linesAdded: 5, linesRemoved: 3 }),
|
|
542
|
-
],
|
|
543
|
-
});
|
|
544
|
-
expect(changeSetLineStats(set)).toEqual({ linesAdded: 15, linesRemoved: 5 });
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
it("treats an uncountable (binary/withheld) file as a zero contributor — honest understatement", () => {
|
|
548
|
-
const set = makeSet({
|
|
549
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
550
|
-
changes: [
|
|
551
|
-
makeChange({ id: "txt", path: "app.ts", linesAdded: 8, linesRemoved: 1 }),
|
|
552
|
-
makeChange({ id: "img", path: "logo.png", kind: FileChangeKind.BINARY_CHANGE, binary: true }),
|
|
553
|
-
],
|
|
554
|
-
});
|
|
555
|
-
expect(changeSetLineStats(set)).toEqual({ linesAdded: 8, linesRemoved: 1 });
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
it("is zero for a set whose files carry no counts (stat then hidden by the atom)", () => {
|
|
559
|
-
const set = makeSet({
|
|
560
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
561
|
-
changes: [makeChange({ id: "a", path: "one.ts" })],
|
|
562
|
-
});
|
|
563
|
-
expect(changeSetLineStats(set)).toEqual({ linesAdded: 0, linesRemoved: 0 });
|
|
564
|
-
});
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
// --- Line stats on the review surfaces -----------------------------------
|
|
568
|
-
|
|
569
|
-
describe("FileReviewPrompt — line stats", () => {
|
|
570
|
-
it("shows the set aggregate +N -M on the header", () => {
|
|
571
|
-
const set = makeSet({
|
|
572
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
573
|
-
changes: [
|
|
574
|
-
makeChange({ id: "a", path: "one.ts", linesAdded: 12, linesRemoved: 3 }),
|
|
575
|
-
makeChange({ id: "b", path: "two.ts", linesAdded: 4, linesRemoved: 1 }),
|
|
576
|
-
],
|
|
577
|
-
});
|
|
578
|
-
const out = render(<FileReviewPrompt changeSet={set} onSubmit={() => {}} />).lastFrame() ?? "";
|
|
579
|
-
expect(out).toContain("2 files awaiting review");
|
|
580
|
-
expect(out).toContain("+16");
|
|
581
|
-
expect(out).toContain("-4");
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
it("hides the header aggregate when no file has counts (never +0 -0)", () => {
|
|
585
|
-
const set = makeSet({
|
|
586
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
587
|
-
changes: [makeChange({ id: "a", path: "one.ts" })],
|
|
588
|
-
});
|
|
589
|
-
const out = render(<FileReviewPrompt changeSet={set} onSubmit={() => {}} />).lastFrame() ?? "";
|
|
590
|
-
expect(out).not.toContain("+");
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
it("shows per-file +N -M in per-file mode", async () => {
|
|
594
|
-
const set = makeSet({
|
|
595
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
596
|
-
changes: [
|
|
597
|
-
makeChange({ id: "a", path: "one.ts", linesAdded: 10, linesRemoved: 2 }),
|
|
598
|
-
makeChange({ id: "b", path: "two.ts", linesAdded: 4, linesRemoved: 0 }),
|
|
599
|
-
],
|
|
600
|
-
});
|
|
601
|
-
const { stdin, lastFrame } = renderWithClient(
|
|
602
|
-
<FileReviewPrompt changeSet={set} onSubmit={() => {}} />,
|
|
603
|
-
);
|
|
604
|
-
stdin.write("f");
|
|
605
|
-
await tick();
|
|
606
|
-
const out = lastFrame() ?? "";
|
|
607
|
-
expect(out).toContain("+10");
|
|
608
|
-
expect(out).toContain("+4");
|
|
609
|
-
});
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
describe("FileReviewRecord — line stats", () => {
|
|
613
|
-
it("shows the set aggregate and per-file +N -M", () => {
|
|
614
|
-
const set = makeSet({
|
|
615
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
616
|
-
changes: [
|
|
617
|
-
makeChange({ id: "a", path: "one.ts", linesAdded: 9, linesRemoved: 2 }),
|
|
618
|
-
makeChange({ id: "b", path: "two.ts", linesAdded: 3, linesRemoved: 1 }),
|
|
619
|
-
],
|
|
620
|
-
decisions: [
|
|
621
|
-
{ scope: FileDecisionScope.CHANGE_SET, action: FileDecisionAction.APPROVE },
|
|
622
|
-
],
|
|
623
|
-
});
|
|
624
|
-
const out = render(<FileReviewRecord fileChangeSet={set} />).lastFrame() ?? "";
|
|
625
|
-
expect(out).toContain("+12"); // aggregate 9+3
|
|
626
|
-
expect(out).toContain("+9"); // per-file
|
|
627
|
-
expect(out).toContain("+3"); // per-file
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
it("renders no stat when the set carries no counts", () => {
|
|
631
|
-
const set = makeSet({
|
|
632
|
-
status: FileChangeSetStatus.RECONCILED,
|
|
633
|
-
changes: [makeChange({ id: "a", path: "one.ts" })],
|
|
634
|
-
});
|
|
635
|
-
expect(render(<FileReviewRecord fileChangeSet={set} />).lastFrame() ?? "").not.toContain("+");
|
|
636
|
-
});
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
// --- FileDiffBody ---------------------------------------------------------
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Polls the rendered frame until it contains `needle`, then returns. Replaces a
|
|
643
|
-
* fixed sleep: an artifact fetch resolves on its own microtask schedule (and a
|
|
644
|
-
* truncated side chains a second download-url fetch), so any single delay raced
|
|
645
|
-
* green locally but timed out under CI load. Polling waits exactly as long as
|
|
646
|
-
* the render needs and no longer, and fails with the actual frame on timeout.
|
|
647
|
-
*/
|
|
648
|
-
async function waitForFrame(
|
|
649
|
-
lastFrame: () => string | undefined,
|
|
650
|
-
needle: string,
|
|
651
|
-
timeoutMs = 2000,
|
|
652
|
-
): Promise<void> {
|
|
653
|
-
const deadline = Date.now() + timeoutMs;
|
|
654
|
-
for (;;) {
|
|
655
|
-
if ((lastFrame() ?? "").includes(needle)) return;
|
|
656
|
-
if (Date.now() >= deadline) {
|
|
657
|
-
throw new Error(
|
|
658
|
-
`waitForFrame timed out after ${timeoutMs}ms waiting for ${JSON.stringify(
|
|
659
|
-
needle,
|
|
660
|
-
)}; last frame was ${JSON.stringify(lastFrame() ?? "")}`,
|
|
661
|
-
);
|
|
662
|
-
}
|
|
663
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function inlineSide(text: string): FileContent {
|
|
668
|
-
return create(FileContentSchema, { body: { case: "inline", value: text } });
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function offloadedSide(storageKey: string, contentHash = "h"): FileContent {
|
|
672
|
-
return create(FileContentSchema, {
|
|
673
|
-
body: {
|
|
674
|
-
case: "ref",
|
|
675
|
-
value: create(ToolCallOutputRefSchema, { storageKey, contentHash }),
|
|
676
|
-
},
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function changeWith(opts: {
|
|
681
|
-
id: string;
|
|
682
|
-
kind?: FileChangeKind;
|
|
683
|
-
before?: FileContent;
|
|
684
|
-
after?: FileContent;
|
|
685
|
-
}) {
|
|
686
|
-
return create(CapturedFileChangeSchema, {
|
|
687
|
-
id: opts.id,
|
|
688
|
-
pathAfter: "file.ts",
|
|
689
|
-
pathBefore: "file.ts",
|
|
690
|
-
kind: opts.kind ?? FileChangeKind.MODIFY,
|
|
691
|
-
fileDigest: `fd-${opts.id}`,
|
|
692
|
-
before: opts.before,
|
|
693
|
-
after: opts.after,
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
describe("FileDiffBody", () => {
|
|
698
|
-
it("renders +/- lines for an inline whole-file diff (computeDiff parity)", () => {
|
|
699
|
-
const change = changeWith({
|
|
700
|
-
id: "m",
|
|
701
|
-
before: inlineSide("keep\nold\ntail\n"),
|
|
702
|
-
after: inlineSide("keep\nnew\ntail\n"),
|
|
703
|
-
});
|
|
704
|
-
const out = renderWithClient(<FileDiffBody change={change} />).lastFrame() ?? "";
|
|
705
|
-
expect(out).toContain("-old"); // removed, red
|
|
706
|
-
expect(out).toContain("+new"); // added, green
|
|
707
|
-
expect(out).toContain("keep"); // context retained
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
it("shows 'Binary file changed.' for a binary side", () => {
|
|
711
|
-
const change = create(CapturedFileChangeSchema, {
|
|
712
|
-
id: "bin",
|
|
713
|
-
pathAfter: "logo.png",
|
|
714
|
-
pathBefore: "logo.png",
|
|
715
|
-
kind: FileChangeKind.BINARY_CHANGE,
|
|
716
|
-
before: create(FileContentSchema, { isBinary: true }),
|
|
717
|
-
after: create(FileContentSchema, { isBinary: true }),
|
|
718
|
-
});
|
|
719
|
-
const out = renderWithClient(<FileDiffBody change={change} />).lastFrame() ?? "";
|
|
720
|
-
expect(out).toContain("Binary file changed.");
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
it("labels a genuinely empty new file (CREATE with no content)", () => {
|
|
724
|
-
const change = changeWith({ id: "new", kind: FileChangeKind.ADD, after: inlineSide("") });
|
|
725
|
-
const out = renderWithClient(<FileDiffBody change={change} />).lastFrame() ?? "";
|
|
726
|
-
expect(out).toContain("Empty new file.");
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
it("falls back to 'No preview available.' for a MODIFY with no renderable diff", () => {
|
|
730
|
-
const change = changeWith({ id: "np" }); // no before/after content
|
|
731
|
-
const out = renderWithClient(<FileDiffBody change={change} />).lastFrame() ?? "";
|
|
732
|
-
expect(out).toContain("No preview available.");
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
it("shows a loading notice while an offloaded side is in flight", () => {
|
|
736
|
-
const change = changeWith({ id: "load", after: offloadedSide("artifacts/aex-1/after") });
|
|
737
|
-
const client = fakeClient({
|
|
738
|
-
agentExecution: { getArtifactContent: () => new Promise(() => {}) },
|
|
739
|
-
});
|
|
740
|
-
const out = renderWithClient(<FileDiffBody change={change} />, client).lastFrame() ?? "";
|
|
741
|
-
expect(out).toContain("Loading diff");
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
it("reports a server-truncated offloaded side as un-diffable inline", async () => {
|
|
745
|
-
const change = changeWith({ id: "trunc", after: offloadedSide("artifacts/aex-1/after") });
|
|
746
|
-
const client = fakeClient({
|
|
747
|
-
agentExecution: {
|
|
748
|
-
getArtifactContent: async () => ({
|
|
749
|
-
content: new TextEncoder().encode("way too big"),
|
|
750
|
-
contentType: "text/plain",
|
|
751
|
-
truncated: true,
|
|
752
|
-
}),
|
|
753
|
-
// A truncated side activates useArtifactDownloadUrl (the notice offers a
|
|
754
|
-
// full-file download). Stub it so the fetch resolves instead of throwing
|
|
755
|
-
// on an undefined method — an unstubbed throw unmounts the ink tree and
|
|
756
|
-
// blanks the frame, which raced green locally but failed under CI load.
|
|
757
|
-
getArtifactDownloadUrl: async () => ({ downloadUrl: "https://example.test/full" }),
|
|
758
|
-
},
|
|
759
|
-
});
|
|
760
|
-
const { lastFrame } = renderWithClient(<FileDiffBody change={change} />, client);
|
|
761
|
-
await waitForFrame(lastFrame, "too large to diff inline");
|
|
762
|
-
expect(lastFrame() ?? "").toContain("too large to diff inline");
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
it("surfaces a fetch failure as an honest error notice", async () => {
|
|
766
|
-
const change = changeWith({ id: "err", after: offloadedSide("artifacts/aex-1/after") });
|
|
767
|
-
const client = fakeClient({
|
|
768
|
-
agentExecution: {
|
|
769
|
-
getArtifactContent: async () => {
|
|
770
|
-
throw new Error("boom");
|
|
771
|
-
},
|
|
772
|
-
},
|
|
773
|
-
});
|
|
774
|
-
const { lastFrame } = renderWithClient(<FileDiffBody change={change} />, client);
|
|
775
|
-
await waitForFrame(lastFrame, "Could not load this file's contents.");
|
|
776
|
-
expect(lastFrame() ?? "").toContain("Could not load this file's contents.");
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
it("caps an oversized diff and reports the overflow", () => {
|
|
780
|
-
const many = Array.from({ length: 250 }, (_, i) => `line ${i}`).join("\n");
|
|
781
|
-
const change = changeWith({ id: "big", before: inlineSide(""), after: inlineSide(`${many}\n`) });
|
|
782
|
-
const out = renderWithClient(<FileDiffBody change={change} />).lastFrame() ?? "";
|
|
783
|
-
expect(out).toMatch(/… \d+ more lines/);
|
|
784
|
-
});
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
describe("FileReviewPrompt — inline diff (master-detail)", () => {
|
|
788
|
-
it("shows only the selected file's diff and follows the selection", async () => {
|
|
789
|
-
const set = makeSet({
|
|
790
|
-
status: FileChangeSetStatus.AWAITING_REVIEW,
|
|
791
|
-
changes: [
|
|
792
|
-
changeWith({ id: "a", before: inlineSide("alpha\n"), after: inlineSide("ALPHA\n") }),
|
|
793
|
-
changeWith({ id: "b", before: inlineSide("bravo\n"), after: inlineSide("BRAVO\n") }),
|
|
794
|
-
],
|
|
795
|
-
});
|
|
796
|
-
const { stdin, lastFrame } = renderWithClient(
|
|
797
|
-
<FileReviewPrompt changeSet={set} onSubmit={() => {}} />,
|
|
798
|
-
);
|
|
799
|
-
stdin.write("f");
|
|
800
|
-
await tick();
|
|
801
|
-
const first = lastFrame() ?? "";
|
|
802
|
-
expect(first).toContain("+ALPHA"); // selected file's diff
|
|
803
|
-
expect(first).not.toContain("+BRAVO"); // the other file's diff is not mounted
|
|
804
|
-
|
|
805
|
-
stdin.write("\u001B[B"); // down → select the second file
|
|
806
|
-
await tick();
|
|
807
|
-
const second = lastFrame() ?? "";
|
|
808
|
-
expect(second).toContain("+BRAVO");
|
|
809
|
-
expect(second).not.toContain("+ALPHA");
|
|
810
|
-
});
|
|
811
|
-
});
|