@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.3
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/CHANGELOG.md +113 -1
- package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
- package/dist/types/cli/startup-cwd.d.ts +2 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-provider-priority.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/config/settings.d.ts +7 -2
- package/dist/types/debug/report-bundle.d.ts +3 -0
- package/dist/types/edit/file-snapshot-store.d.ts +18 -10
- package/dist/types/edit/index.d.ts +0 -1
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -1
- package/dist/types/lsp/client.d.ts +10 -0
- package/dist/types/lsp/index.d.ts +0 -5
- package/dist/types/main.d.ts +14 -9
- package/dist/types/mcp/tool-bridge.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +0 -9
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-selector.d.ts +16 -7
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +0 -18
- package/dist/types/modes/controllers/event-controller.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/magic-keywords.d.ts +1 -1
- package/dist/types/modes/markdown-prose.d.ts +1 -1
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/modes/workflow.d.ts +3 -3
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/messages.d.ts +11 -8
- package/dist/types/session/session-manager.d.ts +5 -2
- package/dist/types/session/yield-queue.d.ts +10 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/tools/eval-render.d.ts +0 -1
- package/dist/types/tools/eval.d.ts +8 -0
- package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
- package/dist/types/tools/github-cache.d.ts +12 -0
- package/dist/types/tools/index.d.ts +31 -0
- package/dist/types/tools/path-utils.d.ts +13 -1
- package/dist/types/tools/read.d.ts +2 -1
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/renderers.d.ts +0 -15
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/write.d.ts +0 -2
- package/dist/types/tools/yield.d.ts +8 -0
- package/dist/types/tui/code-cell.d.ts +0 -2
- package/dist/types/tui/hyperlink.d.ts +5 -7
- package/dist/types/tui/output-block.d.ts +0 -18
- package/package.json +9 -9
- package/src/cli/args.ts +3 -1
- package/src/cli/dry-balance-cli.ts +2 -4
- package/src/cli/gallery-cli.ts +4 -0
- package/src/cli/gallery-fixtures/codeintel.ts +0 -1
- package/src/cli/gallery-fixtures/fs.ts +68 -1
- package/src/cli/gallery-fixtures/types.ts +8 -1
- package/src/cli/startup-cwd.ts +68 -0
- package/src/commands/launch.ts +3 -0
- package/src/commit/agentic/agent.ts +1 -0
- package/src/commit/model-selection.ts +3 -2
- package/src/config/model-provider-priority.ts +55 -0
- package/src/config/model-registry.ts +4 -22
- package/src/config/model-resolver.ts +39 -7
- package/src/config/settings.ts +86 -41
- package/src/debug/index.ts +8 -0
- package/src/debug/raw-sse-buffer.ts +7 -4
- package/src/debug/report-bundle.ts +9 -0
- package/src/edit/file-snapshot-store.ts +33 -1
- package/src/edit/hashline/diff.ts +86 -0
- package/src/edit/hashline/execute.ts +14 -1
- package/src/edit/hashline/filesystem.ts +2 -1
- package/src/edit/index.ts +31 -17
- package/src/edit/renderer.ts +116 -31
- package/src/eval/__tests__/llm-bridge.test.ts +20 -0
- package/src/eval/js/context-manager.ts +32 -15
- package/src/eval/js/shared/prelude.txt +26 -10
- package/src/eval/llm-bridge.ts +14 -3
- package/src/eval/py/__tests__/prelude.test.ts +19 -0
- package/src/eval/py/executor.ts +23 -11
- package/src/eval/py/prelude.py +1 -1
- package/src/extensibility/extensions/types.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/lsp/client.ts +23 -11
- package/src/lsp/config.ts +11 -1
- package/src/lsp/index.ts +189 -61
- package/src/main.ts +144 -78
- package/src/mcp/tool-bridge.ts +2 -0
- package/src/memories/index.ts +2 -2
- package/src/modes/components/assistant-message.ts +3 -15
- package/src/modes/components/custom-editor.ts +143 -111
- package/src/modes/components/late-diagnostics-message.ts +60 -0
- package/src/modes/components/model-selector.ts +59 -13
- package/src/modes/components/oauth-selector.ts +33 -7
- package/src/modes/components/plan-review-overlay.ts +26 -5
- package/src/modes/components/read-tool-group.ts +415 -35
- package/src/modes/components/session-selector.ts +89 -35
- package/src/modes/components/status-line.ts +19 -4
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/tool-execution.ts +7 -49
- package/src/modes/components/transcript-container.ts +108 -32
- package/src/modes/components/user-message.ts +1 -1
- package/src/modes/controllers/event-controller.ts +32 -1
- package/src/modes/controllers/input-controller.ts +56 -9
- package/src/modes/interactive-mode.ts +107 -20
- package/src/modes/magic-keywords.ts +1 -1
- package/src/modes/markdown-prose.ts +1 -1
- package/src/modes/theme/shimmer.ts +20 -9
- package/src/modes/types.ts +7 -0
- package/src/modes/utils/ui-helpers.ts +26 -5
- package/src/modes/workflow.ts +10 -10
- package/src/prompts/system/manual-continue.md +7 -0
- package/src/prompts/system/plan-mode-active.md +56 -72
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +9 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +85 -10
- package/src/session/agent-session.ts +42 -15
- package/src/session/auth-storage.ts +2 -0
- package/src/session/messages.ts +21 -14
- package/src/session/session-manager.ts +98 -25
- package/src/session/yield-queue.ts +20 -2
- package/src/task/executor.ts +72 -36
- package/src/task/render.ts +3 -4
- package/src/tiny/title-client.ts +6 -1
- package/src/tools/bash.ts +7 -7
- package/src/tools/browser/tab-supervisor.ts +13 -1
- package/src/tools/browser/tab-worker.ts +33 -4
- package/src/tools/eval-render.ts +4 -23
- package/src/tools/eval.ts +13 -2
- package/src/tools/find.ts +148 -99
- package/src/tools/gh-cache-invalidation.ts +200 -0
- package/src/tools/github-cache.ts +25 -0
- package/src/tools/index.ts +32 -0
- package/src/tools/inspect-image.ts +2 -2
- package/src/tools/path-utils.ts +47 -24
- package/src/tools/plan-mode-guard.ts +52 -7
- package/src/tools/read.ts +41 -20
- package/src/tools/render-utils.ts +3 -1
- package/src/tools/renderers.ts +0 -15
- package/src/tools/search.ts +38 -3
- package/src/tools/ssh.ts +0 -1
- package/src/tools/todo.ts +1 -0
- package/src/tools/write.ts +5 -14
- package/src/tools/yield.ts +10 -1
- package/src/tui/code-cell.ts +1 -6
- package/src/tui/hyperlink.ts +13 -23
- package/src/tui/output-block.ts +2 -97
- package/src/utils/commit-message-generator.ts +2 -2
- package/src/utils/enhanced-paste.ts +30 -2
- package/src/web/search/providers/codex.ts +37 -8
package/src/debug/index.ts
CHANGED
|
@@ -195,6 +195,7 @@ export class DebugSelectorComponent extends Container {
|
|
|
195
195
|
const result = await createReportBundle({
|
|
196
196
|
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
|
197
197
|
settings: this.#getResolvedSettings(),
|
|
198
|
+
rawSseText: this.#getRawSseText(),
|
|
198
199
|
cpuProfile,
|
|
199
200
|
workProfile,
|
|
200
201
|
});
|
|
@@ -253,6 +254,7 @@ export class DebugSelectorComponent extends Container {
|
|
|
253
254
|
const result = await createReportBundle({
|
|
254
255
|
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
|
255
256
|
settings: this.#getResolvedSettings(),
|
|
257
|
+
rawSseText: this.#getRawSseText(),
|
|
256
258
|
});
|
|
257
259
|
|
|
258
260
|
loader.stop();
|
|
@@ -288,6 +290,7 @@ export class DebugSelectorComponent extends Container {
|
|
|
288
290
|
const result = await createReportBundle({
|
|
289
291
|
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
|
290
292
|
settings: this.#getResolvedSettings(),
|
|
293
|
+
rawSseText: this.#getRawSseText(),
|
|
291
294
|
heapSnapshot,
|
|
292
295
|
});
|
|
293
296
|
|
|
@@ -490,6 +493,11 @@ export class DebugSelectorComponent extends Container {
|
|
|
490
493
|
}
|
|
491
494
|
}
|
|
492
495
|
|
|
496
|
+
#getRawSseText(): string | undefined {
|
|
497
|
+
const rawSseText = resolveRawSseDebugBuffer(this.ctx.session).toRawText();
|
|
498
|
+
return rawSseText.trim().length > 0 ? rawSseText : undefined;
|
|
499
|
+
}
|
|
500
|
+
|
|
493
501
|
#getResolvedSettings(): Record<string, unknown> {
|
|
494
502
|
// Extract key settings for the report
|
|
495
503
|
return {
|
|
@@ -152,9 +152,9 @@ export class RawSseDebugBuffer {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
// Ownership contract for `event.raw`:
|
|
155
|
-
// The caller (
|
|
156
|
-
//
|
|
157
|
-
//
|
|
155
|
+
// The caller (`notifyRawSseEvent` in `packages/ai/src/utils/sse-debug.ts`)
|
|
156
|
+
// hands us a freshly-allocated `string[]` per event and never retains,
|
|
157
|
+
// mutates, or re-dispatches it.
|
|
158
158
|
// That lets `trimRawLines` keep the array by reference instead of
|
|
159
159
|
// cloning on every chunk — a measurable savings on the streaming hot
|
|
160
160
|
// path. If a future observer-chain mutates the array, restore the
|
|
@@ -192,7 +192,10 @@ export class RawSseDebugBuffer {
|
|
|
192
192
|
toRawText(): string {
|
|
193
193
|
// Reads the live array directly: `rawRecordText` only computes a string
|
|
194
194
|
// from each record, so no caller-visible mutation is possible.
|
|
195
|
-
|
|
195
|
+
const body = this.#records.map(rawRecordText).join("\n");
|
|
196
|
+
if (this.#droppedRecords === 0) return body;
|
|
197
|
+
const dropped = `: omp-debug-dropped records=${this.#droppedRecords} chars=${this.#droppedChars}\n\n`;
|
|
198
|
+
return body.length > 0 ? `${dropped}${body}` : dropped;
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
#append(record: RawSseDebugRecord, chars: number): void {
|
|
@@ -45,6 +45,8 @@ export interface ReportBundleOptions {
|
|
|
45
45
|
heapSnapshot?: HeapSnapshot;
|
|
46
46
|
/** Work profile (for work scheduling reports) */
|
|
47
47
|
workProfile?: WorkProfile;
|
|
48
|
+
/** Raw provider SSE diagnostics captured by the session buffer */
|
|
49
|
+
rawSseText?: string;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export interface ReportBundleResult {
|
|
@@ -70,6 +72,7 @@ export interface DebugLogSource {
|
|
|
70
72
|
* - env.json: Sanitized environment variables
|
|
71
73
|
* - config.json: Resolved settings
|
|
72
74
|
* - profile.cpuprofile: CPU profile (performance report only)
|
|
75
|
+
* - raw-sse.txt: Recent raw provider SSE diagnostics (when captured)
|
|
73
76
|
* - profile.md: Markdown CPU profile (performance report only)
|
|
74
77
|
* - heap.heapsnapshot: Heap snapshot (memory report only)
|
|
75
78
|
* - work.folded: Work profile folded stacks (work report only)
|
|
@@ -109,6 +112,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
|
|
|
109
112
|
files.push("logs.txt");
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
// Recent raw provider SSE diagnostics
|
|
116
|
+
if (options.rawSseText && options.rawSseText.trim().length > 0) {
|
|
117
|
+
data["raw-sse.txt"] = options.rawSseText;
|
|
118
|
+
files.push("raw-sse.txt");
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
// Session file
|
|
113
122
|
if (options.sessionFile) {
|
|
114
123
|
try {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* from `@oh-my-pi/hashline`; the only coding-agent-specific concern here
|
|
9
9
|
* is wiring it onto the per-session owner object.
|
|
10
10
|
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
11
13
|
import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
12
14
|
import { normalizeToLF } from "./normalize";
|
|
13
15
|
|
|
@@ -33,6 +35,36 @@ export function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemoryS
|
|
|
33
35
|
return session.fileSnapshotStore;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Canonicalize an absolute path into the stable key the snapshot store uses.
|
|
40
|
+
*
|
|
41
|
+
* Different code paths reach the snapshot store via different path forms:
|
|
42
|
+
* `read local://foo.md` records under the file's `fs.realpath` (the local
|
|
43
|
+
* protocol handler resolves symlinks); a subsequent `edit` may address the
|
|
44
|
+
* same artifact via `local://foo.md`, whose resolver does NOT realpath, or
|
|
45
|
+
* via the absolute path returned in the `[path#tag]` header. macOS adds the
|
|
46
|
+
* same hazard at the working-tree level (`/tmp/...` vs `/private/tmp/...`).
|
|
47
|
+
* Collapsing every key through `realpath` makes those forms fuse onto one
|
|
48
|
+
* snapshot entry, so a freshly-minted tag is never rejected as stale just
|
|
49
|
+
* because the lookup spelled the same file differently.
|
|
50
|
+
*
|
|
51
|
+
* Non-existent paths (new-file writes) fall back to a realpath of the parent
|
|
52
|
+
* directory + basename, then to the input. This keeps creates and updates on
|
|
53
|
+
* the same canonical key.
|
|
54
|
+
*/
|
|
55
|
+
export function canonicalSnapshotKey(absolutePath: string): string {
|
|
56
|
+
try {
|
|
57
|
+
return fs.realpathSync.native(absolutePath);
|
|
58
|
+
} catch {
|
|
59
|
+
try {
|
|
60
|
+
const parent = fs.realpathSync.native(path.dirname(absolutePath));
|
|
61
|
+
return path.join(parent, path.basename(absolutePath));
|
|
62
|
+
} catch {
|
|
63
|
+
return absolutePath;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
36
68
|
/**
|
|
37
69
|
* Read the full text of `absolutePath` (within {@link SNAPSHOT_MAX_BYTES}),
|
|
38
70
|
* record it as a version snapshot, and return its content-hash tag. Returns
|
|
@@ -52,7 +84,7 @@ export async function recordFileSnapshot(
|
|
|
52
84
|
const file = Bun.file(absolutePath);
|
|
53
85
|
if (file.size > SNAPSHOT_MAX_BYTES) return undefined;
|
|
54
86
|
const normalized = normalizeToLF(await file.text());
|
|
55
|
-
return getFileSnapshotStore(session).record(absolutePath, normalized);
|
|
87
|
+
return getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
|
|
56
88
|
} catch {
|
|
57
89
|
return undefined;
|
|
58
90
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {
|
|
13
13
|
type ApplyResult,
|
|
14
14
|
applyEdits,
|
|
15
|
+
type Cursor,
|
|
15
16
|
computeFileHash,
|
|
16
17
|
type Edit,
|
|
17
18
|
Patch as HashlinePatch,
|
|
@@ -131,6 +132,86 @@ function applyPreviewEdits(args: {
|
|
|
131
132
|
throw createMismatchError(section, absolutePath, normalized, snapshots, expected);
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Map an insert cursor to the 1-indexed line where its payload lands, used to
|
|
137
|
+
* number the `+` rows of a streaming preview. Deliberately approximate: it
|
|
138
|
+
* ignores line shifts introduced by sibling ops, because the args-complete
|
|
139
|
+
* pass renumbers everything through the real unified diff.
|
|
140
|
+
*/
|
|
141
|
+
function insertCursorLine(cursor: Cursor, fileLineCount: number): number {
|
|
142
|
+
switch (cursor.kind) {
|
|
143
|
+
case "bof":
|
|
144
|
+
return 1;
|
|
145
|
+
case "eof":
|
|
146
|
+
return fileLineCount + 1;
|
|
147
|
+
case "before_anchor":
|
|
148
|
+
return cursor.anchor.line;
|
|
149
|
+
case "after_anchor":
|
|
150
|
+
return cursor.anchor.line + 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build a streaming diff preview by emitting, per op in patch order, the
|
|
156
|
+
* removed file lines followed by the op's `+` payload rows — never a whole-file
|
|
157
|
+
* Myers re-diff. {@link generateDiffString} re-aligns the in-flight payload
|
|
158
|
+
* against the removed block on every streamed chunk (it greedily matches shared
|
|
159
|
+
* `}`/blank/`return` rows), so additions jump between hunks and the tail window
|
|
160
|
+
* the renderer pins stutters tick to tick. Natural order keeps the removed
|
|
161
|
+
* block fixed and grows the payload monotonically at the bottom so the streamed
|
|
162
|
+
* cursor stays put. Mirrors the apply_patch streaming strategy; the
|
|
163
|
+
* args-complete pass still produces the real unified diff.
|
|
164
|
+
*/
|
|
165
|
+
function buildStreamingSectionDiff(
|
|
166
|
+
section: PatchSection,
|
|
167
|
+
normalized: string,
|
|
168
|
+
): { diff: string; firstChangedLine: number | undefined } | { error: string } {
|
|
169
|
+
const { edits } = parsePatchStreaming(section.diff);
|
|
170
|
+
const resolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, { onUnresolved: "drop" });
|
|
171
|
+
if (resolved.length === 0) return { error: `No changes would be made to ${section.path}.` };
|
|
172
|
+
|
|
173
|
+
const fileLines = normalized.split("\n");
|
|
174
|
+
const rows: string[] = [];
|
|
175
|
+
let firstChangedLine: number | undefined;
|
|
176
|
+
|
|
177
|
+
// Every edit emitted from one op header carries that header's patch line
|
|
178
|
+
// number and the edits sit contiguously (a replace lays down its replacement
|
|
179
|
+
// inserts then its range deletes; block ops expand to the same shape). Group
|
|
180
|
+
// on that boundary so each op stays intact and ordered.
|
|
181
|
+
for (let i = 0; i < resolved.length; ) {
|
|
182
|
+
const opLine = resolved[i].lineNum;
|
|
183
|
+
const deletes: number[] = [];
|
|
184
|
+
const inserts: string[] = [];
|
|
185
|
+
let insertBase: number | undefined;
|
|
186
|
+
while (i < resolved.length && resolved[i].lineNum === opLine) {
|
|
187
|
+
const edit = resolved[i];
|
|
188
|
+
if (edit.kind === "delete") deletes.push(edit.anchor.line);
|
|
189
|
+
else if (edit.kind === "insert") {
|
|
190
|
+
insertBase ??= insertCursorLine(edit.cursor, fileLines.length);
|
|
191
|
+
inserts.push(edit.text);
|
|
192
|
+
}
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
// Removed lines first (a fixed block), payload second (grows at the
|
|
196
|
+
// bottom = the streamed cursor).
|
|
197
|
+
deletes.sort((a, b) => a - b);
|
|
198
|
+
for (const line of deletes) {
|
|
199
|
+
firstChangedLine ??= line;
|
|
200
|
+
const content = line >= 1 && line <= fileLines.length ? fileLines[line - 1] : "";
|
|
201
|
+
rows.push(`-${line}|${content}`);
|
|
202
|
+
}
|
|
203
|
+
let newLine = insertBase ?? deletes[0] ?? 1;
|
|
204
|
+
for (const text of inserts) {
|
|
205
|
+
firstChangedLine ??= newLine;
|
|
206
|
+
rows.push(`+${newLine}|${text}`);
|
|
207
|
+
newLine++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (rows.length === 0) return { error: `No changes would be made to ${section.path}.` };
|
|
212
|
+
return { diff: rows.join("\n"), firstChangedLine };
|
|
213
|
+
}
|
|
214
|
+
|
|
134
215
|
export async function computeHashlineSectionDiff(
|
|
135
216
|
section: PatchSection,
|
|
136
217
|
cwd: string,
|
|
@@ -142,6 +223,11 @@ export async function computeHashlineSectionDiff(
|
|
|
142
223
|
const rawContent = await readSectionText(absolutePath, section.path);
|
|
143
224
|
const { text: content } = stripBom(rawContent);
|
|
144
225
|
const normalized = normalizeToLF(content);
|
|
226
|
+
// Streaming favors a stable, monotonic preview over an exact unified
|
|
227
|
+
// diff: feed the in-flight ops through the natural-order builder so the
|
|
228
|
+
// streamed cursor stays pinned to the bottom. The args-complete pass
|
|
229
|
+
// (`streaming` unset) falls through to the real Myers diff below.
|
|
230
|
+
if (options.streaming) return buildStreamingSectionDiff(section, normalized);
|
|
145
231
|
const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
|
|
146
232
|
if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
|
|
147
233
|
return generateDiffString(normalized, result.text);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* round-trip once.
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
|
+
type BlockResolution,
|
|
14
15
|
buildCompactDiffPreview,
|
|
15
16
|
MismatchError as HashlineMismatchError,
|
|
16
17
|
Patch,
|
|
@@ -76,6 +77,14 @@ interface RenderedSection {
|
|
|
76
77
|
perFileResult: EditToolPerFileResult;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
function formatBlockResolution(resolution: BlockResolution): string {
|
|
81
|
+
const op = resolution.isDelete ? "delete block" : "replace block";
|
|
82
|
+
const lines = resolution.end - resolution.start + 1;
|
|
83
|
+
const span =
|
|
84
|
+
resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
|
|
85
|
+
return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})`;
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection {
|
|
80
89
|
if (result.op === "noop") {
|
|
81
90
|
const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
|
|
@@ -96,10 +105,14 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
|
|
|
96
105
|
|
|
97
106
|
const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
98
107
|
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
108
|
+
const blockBlock =
|
|
109
|
+
result.blockResolutions && result.blockResolutions.length > 0
|
|
110
|
+
? `\n${result.blockResolutions.map(formatBlockResolution).join("\n")}`
|
|
111
|
+
: "";
|
|
99
112
|
const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
|
|
100
113
|
return {
|
|
101
114
|
toolResult: {
|
|
102
|
-
content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
|
|
115
|
+
content: [{ type: "text", text: `${result.header}${blockBlock}${previewBlock}${warningsBlock}` }],
|
|
103
116
|
details: {
|
|
104
117
|
diff: diff.diff,
|
|
105
118
|
firstChangedLine,
|
|
@@ -23,6 +23,7 @@ import type { ToolSession } from "../../tools";
|
|
|
23
23
|
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
24
24
|
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
25
25
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
26
|
+
import { canonicalSnapshotKey } from "../file-snapshot-store";
|
|
26
27
|
import { readEditFileText, serializeEditFileText } from "../read-file";
|
|
27
28
|
import type { LspBatchRequest } from "../renderer";
|
|
28
29
|
|
|
@@ -81,7 +82,7 @@ export class HashlineFilesystem extends Filesystem {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
canonicalPath(relativePath: string): string {
|
|
84
|
-
return this.resolveAbsolute(relativePath);
|
|
85
|
+
return canonicalSnapshotKey(this.resolveAbsolute(relativePath));
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
async readText(relativePath: string): Promise<string> {
|
package/src/edit/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
|
|
|
14
14
|
import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
|
|
15
15
|
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
|
|
16
16
|
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
|
|
17
|
-
import type { ToolSession } from "../tools";
|
|
17
|
+
import type { DeferredDiagnosticsEntry, ToolSession } from "../tools";
|
|
18
18
|
import { truncateForPrompt } from "../tools/approval";
|
|
19
19
|
import { isInternalUrlPath } from "../tools/path-utils";
|
|
20
20
|
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
|
@@ -297,7 +297,6 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
297
297
|
readonly name = "edit";
|
|
298
298
|
readonly label = "Edit";
|
|
299
299
|
readonly loadMode = "essential";
|
|
300
|
-
readonly nonAbortable = true;
|
|
301
300
|
readonly concurrency = "exclusive";
|
|
302
301
|
readonly strict = true;
|
|
303
302
|
|
|
@@ -307,6 +306,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
307
306
|
readonly #editMode?: EditMode;
|
|
308
307
|
readonly #dedupDiagnostics: boolean;
|
|
309
308
|
readonly #pendingDeferredFetches = new Map<string, AbortController>();
|
|
309
|
+
/** Fallback per-path mutation counter used only when the session does not expose
|
|
310
|
+
* a shared one. Prefer `session.bumpFileMutationVersion` so write (and any other
|
|
311
|
+
* tool) mutating the same file also invalidates pending late-diagnostics. */
|
|
312
|
+
readonly #editVersionByPath = new Map<string, number>();
|
|
310
313
|
|
|
311
314
|
constructor(private readonly session: ToolSession) {
|
|
312
315
|
const {
|
|
@@ -503,10 +506,11 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
503
506
|
}
|
|
504
507
|
|
|
505
508
|
const deferredController = new AbortController();
|
|
509
|
+
const editVersion = this.#bumpFileVersion(path);
|
|
506
510
|
return {
|
|
507
511
|
onDeferredDiagnostics: (lateDiagnostics: FileDiagnosticsResult) => {
|
|
508
512
|
this.#pendingDeferredFetches.delete(path);
|
|
509
|
-
this.#injectLateDiagnostics(path, lateDiagnostics);
|
|
513
|
+
this.#injectLateDiagnostics(path, lateDiagnostics, editVersion);
|
|
510
514
|
},
|
|
511
515
|
signal: deferredController.signal,
|
|
512
516
|
finalize: (diagnostics: FileDiagnosticsResult | undefined) => {
|
|
@@ -519,24 +523,34 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
519
523
|
};
|
|
520
524
|
}
|
|
521
525
|
|
|
522
|
-
#injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult): void {
|
|
526
|
+
#injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult, editVersion: number): void {
|
|
523
527
|
const effective = this.#dedupDiagnostics
|
|
524
528
|
? getDiagnosticsLedger(this.session).reduce(path, diagnostics)
|
|
525
529
|
: diagnostics;
|
|
526
530
|
if (this.#dedupDiagnostics && effective.messages.length === 0) return;
|
|
527
531
|
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
.
|
|
532
|
-
.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
532
|
+
const entry: DeferredDiagnosticsEntry = {
|
|
533
|
+
path,
|
|
534
|
+
summary: effective.summary ?? "",
|
|
535
|
+
messages: effective.messages ?? [],
|
|
536
|
+
errored: effective.errored,
|
|
537
|
+
// Drop at flush time if a later edit to the same file superseded this fetch.
|
|
538
|
+
isStale: () => this.#fileVersion(path) !== editVersion,
|
|
539
|
+
};
|
|
540
|
+
this.session.queueDeferredDiagnostics?.(entry);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Bump the file's mutation counter (session-global when available). */
|
|
544
|
+
#bumpFileVersion(path: string): number {
|
|
545
|
+
if (this.session.bumpFileMutationVersion) return this.session.bumpFileMutationVersion(path);
|
|
546
|
+
const next = (this.#editVersionByPath.get(path) ?? 0) + 1;
|
|
547
|
+
this.#editVersionByPath.set(path, next);
|
|
548
|
+
return next;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Read the file's current mutation counter (session-global when available). */
|
|
552
|
+
#fileVersion(path: string): number {
|
|
553
|
+
if (this.session.getFileMutationVersion) return this.session.getFileMutationVersion(path);
|
|
554
|
+
return this.#editVersionByPath.get(path) ?? 0;
|
|
541
555
|
}
|
|
542
556
|
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
|
|
6
6
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
7
|
-
import { visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
7
|
+
import { sliceWithWidth, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
8
8
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
9
9
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
10
10
|
import type { FileDiagnosticsResult } from "../lsp";
|
|
@@ -13,7 +13,6 @@ import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
|
|
13
13
|
import type { OutputMeta } from "../tools/output-meta";
|
|
14
14
|
import {
|
|
15
15
|
formatDiagnostics,
|
|
16
|
-
formatDiffStats,
|
|
17
16
|
formatExpandHint,
|
|
18
17
|
formatStatusIcon,
|
|
19
18
|
getDiffStats,
|
|
@@ -182,42 +181,118 @@ function getOperationTitle(op: Operation | undefined): string {
|
|
|
182
181
|
return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
|
|
183
182
|
}
|
|
184
183
|
|
|
184
|
+
interface EditPathDisplayOptions {
|
|
185
|
+
rename?: string;
|
|
186
|
+
firstChangedLine?: number;
|
|
187
|
+
linkPath?: string;
|
|
188
|
+
renameLinkPath?: string;
|
|
189
|
+
maxPathWidth?: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function truncateEditTitlePath(displayPath: string, maxWidth: number | undefined): string {
|
|
193
|
+
if (maxWidth === undefined) return displayPath;
|
|
194
|
+
const width = visibleWidth(displayPath);
|
|
195
|
+
const safeMaxWidth = Math.max(0, Math.floor(maxWidth));
|
|
196
|
+
if (width <= safeMaxWidth) return displayPath;
|
|
197
|
+
|
|
198
|
+
const contentWidth = safeMaxWidth - 1;
|
|
199
|
+
if (contentWidth <= 0) return "…";
|
|
200
|
+
|
|
201
|
+
const headWidth = Math.floor(contentWidth / 2);
|
|
202
|
+
const tailWidth = contentWidth - headWidth;
|
|
203
|
+
const head = sliceWithWidth(displayPath, 0, headWidth, true).text;
|
|
204
|
+
const tail = sliceWithWidth(displayPath, Math.max(0, width - tailWidth), tailWidth, true).text;
|
|
205
|
+
return `${head}…${tail}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatEditTitlePath(pathValue: string, maxWidth?: number): string {
|
|
209
|
+
return truncateEditTitlePath(replaceTabs(shortenPath(pathValue), pathValue), maxWidth);
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
function formatEditPathDisplay(
|
|
186
213
|
rawPath: string,
|
|
187
214
|
uiTheme: Theme,
|
|
188
|
-
options?:
|
|
189
|
-
): string {
|
|
215
|
+
options?: EditPathDisplayOptions,
|
|
216
|
+
): { text: string; pathWidth: number } {
|
|
190
217
|
// `rawPath`/`rename` are shown (cwd-relative) but the OSC 8 link targets the
|
|
191
|
-
// absolute path when known — a relative `rawPath` would yield a
|
|
192
|
-
// URI that resolves against filesystem root instead of cwd.
|
|
218
|
+
// absolute path when known — a relative `rawPath` would otherwise yield a
|
|
219
|
+
// `file:///rel` URI that resolves against filesystem root instead of cwd.
|
|
193
220
|
const linkTarget = options?.linkPath || rawPath;
|
|
221
|
+
const lineLink = options?.firstChangedLine ? { line: options.firstChangedLine } : undefined;
|
|
222
|
+
const primaryDisplay = rawPath ? formatEditTitlePath(rawPath, options?.maxPathWidth) : "…";
|
|
194
223
|
let pathDisplay = rawPath
|
|
195
|
-
? fileHyperlink(linkTarget, uiTheme.fg("accent",
|
|
196
|
-
: uiTheme.fg("toolOutput",
|
|
197
|
-
|
|
198
|
-
if (options?.firstChangedLine) {
|
|
199
|
-
pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`);
|
|
200
|
-
}
|
|
224
|
+
? fileHyperlink(linkTarget, uiTheme.fg("accent", primaryDisplay), lineLink)
|
|
225
|
+
: uiTheme.fg("toolOutput", primaryDisplay);
|
|
226
|
+
let pathWidth = visibleWidth(primaryDisplay);
|
|
201
227
|
|
|
202
228
|
if (options?.rename) {
|
|
203
229
|
const renameTarget = options.renameLinkPath || options.rename;
|
|
204
|
-
|
|
230
|
+
const renameDisplay = formatEditTitlePath(options.rename, options.maxPathWidth);
|
|
231
|
+
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", renameDisplay))}`;
|
|
232
|
+
pathWidth += visibleWidth(renameDisplay);
|
|
205
233
|
}
|
|
206
234
|
|
|
207
|
-
return pathDisplay;
|
|
235
|
+
return { text: pathDisplay, pathWidth };
|
|
208
236
|
}
|
|
209
237
|
|
|
210
238
|
function formatEditDescription(
|
|
211
239
|
rawPath: string,
|
|
212
240
|
uiTheme: Theme,
|
|
213
|
-
options?:
|
|
214
|
-
): { language: string; description: string } {
|
|
241
|
+
options?: EditPathDisplayOptions,
|
|
242
|
+
): { language: string; description: string; pathWidth: number } {
|
|
215
243
|
const language = getLanguageFromPath(rawPath) ?? "text";
|
|
216
244
|
const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
|
|
245
|
+
const pathDisplay = formatEditPathDisplay(rawPath, uiTheme, options);
|
|
217
246
|
return {
|
|
218
247
|
language,
|
|
219
|
-
description: `${icon} ${
|
|
248
|
+
description: `${icon} ${pathDisplay.text}`,
|
|
249
|
+
pathWidth: pathDisplay.pathWidth,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function editHeaderLabelBudget(width: number, uiTheme: Theme): number {
|
|
254
|
+
const leftGlyphs = `${uiTheme.boxSharp.topLeft}${uiTheme.boxSharp.horizontal.repeat(3)}`;
|
|
255
|
+
return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxSharp.topRight) - 2);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderEditHeader(
|
|
259
|
+
width: number,
|
|
260
|
+
uiTheme: Theme,
|
|
261
|
+
options: {
|
|
262
|
+
icon: "pending" | "success" | "error";
|
|
263
|
+
spinnerFrame?: number;
|
|
264
|
+
op?: Operation;
|
|
265
|
+
rawPath: string;
|
|
266
|
+
rename?: string;
|
|
267
|
+
firstChangedLine?: number;
|
|
268
|
+
linkPath?: string;
|
|
269
|
+
statsSuffix?: string;
|
|
270
|
+
extraSuffix?: string;
|
|
271
|
+
},
|
|
272
|
+
): string {
|
|
273
|
+
const title = getOperationTitle(options.op);
|
|
274
|
+
const descriptionOptions: EditPathDisplayOptions = {
|
|
275
|
+
rename: options.rename,
|
|
276
|
+
firstChangedLine: options.firstChangedLine,
|
|
277
|
+
linkPath: options.linkPath,
|
|
220
278
|
};
|
|
279
|
+
const formatted = formatEditDescription(options.rawPath, uiTheme, descriptionOptions);
|
|
280
|
+
const suffix = `${options.statsSuffix ?? ""}${options.extraSuffix ?? ""}`;
|
|
281
|
+
const buildHeader = (description: string): string =>
|
|
282
|
+
renderStatusLine({ icon: options.icon, spinnerFrame: options.spinnerFrame, title, description }, uiTheme) +
|
|
283
|
+
suffix;
|
|
284
|
+
|
|
285
|
+
const header = buildHeader(formatted.description);
|
|
286
|
+
const overflow = visibleWidth(header) - editHeaderLabelBudget(width, uiTheme);
|
|
287
|
+
if (overflow <= 0 || formatted.pathWidth <= 1) return header;
|
|
288
|
+
|
|
289
|
+
const pathCount = Math.max(1, (options.rawPath ? 1 : 0) + (options.rename ? 1 : 0));
|
|
290
|
+
const fittedPathWidth = Math.max(1, Math.floor((formatted.pathWidth - overflow) / pathCount));
|
|
291
|
+
const fitted = formatEditDescription(options.rawPath, uiTheme, {
|
|
292
|
+
...descriptionOptions,
|
|
293
|
+
maxPathWidth: fittedPathWidth,
|
|
294
|
+
});
|
|
295
|
+
return buildHeader(fitted.description);
|
|
221
296
|
}
|
|
222
297
|
|
|
223
298
|
function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string {
|
|
@@ -379,10 +454,13 @@ function getApplyPatchRenderSummary(
|
|
|
379
454
|
}
|
|
380
455
|
|
|
381
456
|
function formatDiffStatsSuffix(diff: string, uiTheme: Theme): string {
|
|
382
|
-
const { added, removed
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
457
|
+
const { added, removed } = getDiffStats(diff);
|
|
458
|
+
if (added === 0 && removed === 0) return "";
|
|
459
|
+
const stats = [
|
|
460
|
+
added > 0 ? uiTheme.fg("toolDiffAdded", `+${added}`) : undefined,
|
|
461
|
+
removed > 0 ? uiTheme.fg("toolDiffRemoved", `-${removed}`) : undefined,
|
|
462
|
+
].filter(value => value !== undefined);
|
|
463
|
+
return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats.join(uiTheme.fg("dim", "/"))}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
|
|
386
464
|
}
|
|
387
465
|
|
|
388
466
|
function renderDiffSection(
|
|
@@ -462,17 +540,19 @@ export const editToolRenderer = {
|
|
|
462
540
|
"";
|
|
463
541
|
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
|
|
464
542
|
const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
|
|
465
|
-
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
|
|
466
543
|
let fileCount = hashlineInputSummary?.entries.length ?? applyPatchSummary?.entries.length ?? 0;
|
|
467
544
|
if (Array.isArray(editArgs.edits)) {
|
|
468
545
|
fileCount = countEditFiles(editArgs.edits);
|
|
469
546
|
}
|
|
470
547
|
return framedBlock(uiTheme, width => {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
548
|
+
const header = renderEditHeader(width, uiTheme, {
|
|
549
|
+
icon: "pending",
|
|
550
|
+
spinnerFrame: options?.spinnerFrame,
|
|
551
|
+
op,
|
|
552
|
+
rawPath,
|
|
553
|
+
rename,
|
|
554
|
+
extraSuffix: fileCount > 1 ? uiTheme.fg("dim", ` (+${fileCount - 1} more)`) : undefined,
|
|
555
|
+
});
|
|
476
556
|
let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded);
|
|
477
557
|
if (applyPatchSummary?.error) {
|
|
478
558
|
body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
|
|
@@ -546,15 +626,20 @@ function renderSingleFileResult(
|
|
|
546
626
|
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
|
|
547
627
|
(details && !isError ? details.firstChangedLine : undefined);
|
|
548
628
|
const linkPath = details && "path" in details ? details.path : undefined;
|
|
549
|
-
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine, linkPath });
|
|
550
629
|
|
|
551
630
|
// Change stats ride inline on the header bar next to the path.
|
|
552
631
|
const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
|
|
553
632
|
const headerDiff = isError ? undefined : details?.diff || previewDiff;
|
|
554
633
|
const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
|
|
555
|
-
const header =
|
|
556
|
-
|
|
557
|
-
|
|
634
|
+
const header = renderEditHeader(width, uiTheme, {
|
|
635
|
+
icon: isError ? "error" : "success",
|
|
636
|
+
op,
|
|
637
|
+
rawPath,
|
|
638
|
+
rename,
|
|
639
|
+
firstChangedLine,
|
|
640
|
+
linkPath,
|
|
641
|
+
statsSuffix,
|
|
642
|
+
});
|
|
558
643
|
|
|
559
644
|
let body = "";
|
|
560
645
|
if (isError) {
|
|
@@ -206,6 +206,26 @@ describe("runEvalLlm", () => {
|
|
|
206
206
|
expect(result.details).toEqual({ model: "p/smol", tier: "smol", structured: false });
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
+
it("supplies a non-empty systemPrompt when system is omitted (codex 'Instructions are required' guard)", async () => {
|
|
210
|
+
// The openai-codex Responses transformer drops `instructions` when no
|
|
211
|
+
// system prompt is provided, and the remote endpoint then 400s with
|
|
212
|
+
// "Instructions are required". runEvalLlm must always carry a non-empty
|
|
213
|
+
// systemPrompt so `llm("…")` without a `system` argument works.
|
|
214
|
+
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
|
215
|
+
await runEvalLlm({ prompt: "q", model: "smol" }, { session: makeSession() });
|
|
216
|
+
const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
|
|
217
|
+
expect(ctx.systemPrompt).toBeDefined();
|
|
218
|
+
expect(ctx.systemPrompt?.length).toBeGreaterThan(0);
|
|
219
|
+
expect(ctx.systemPrompt?.[0]).toMatch(/.+/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("honors an explicit system prompt instead of overriding it", async () => {
|
|
223
|
+
const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
|
|
224
|
+
await runEvalLlm({ prompt: "q", model: "smol", system: "Be terse." }, { session: makeSession() });
|
|
225
|
+
const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
|
|
226
|
+
expect(ctx.systemPrompt).toEqual(["Be terse."]);
|
|
227
|
+
});
|
|
228
|
+
|
|
209
229
|
it("forces a respond tool call and returns its arguments in structured mode", async () => {
|
|
210
230
|
const spy = vi
|
|
211
231
|
.spyOn(ai, "completeSimple")
|