From 94f406d4f0ddfe0e02344ebef7ab0c88f07de52f Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 20 Jan 2026 05:34:39 +0100 Subject: [PATCH] chore: bump version to 6.8.0 --- bun.lock | 12 +- packages/agent/CHANGELOG.md | 2 + packages/agent/package.json | 2 +- packages/ai/CHANGELOG.md | 2 + packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/package.json | 2 +- .../coding-agent/src/core/agent-session.ts | 153 ++++++++++++++---- packages/coding-agent/src/core/exec.ts | 19 ++- .../coding-agent/src/core/streaming-output.ts | 2 + packages/coding-agent/src/core/tools/find.ts | 2 +- packages/coding-agent/src/core/tools/read.ts | 15 +- .../src/prompts/system/ttsr-interrupt.md | 7 + .../test/core/python-executor.test.ts | 8 +- .../test/streaming-edit-abort.test.ts | 8 + packages/coding-agent/test/tools.test.ts | 8 +- packages/git-tool/package.json | 2 +- packages/pi-utils/package.json | 2 +- packages/tui/package.json | 2 +- 19 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 packages/coding-agent/src/prompts/system/ttsr-interrupt.md diff --git a/bun.lock b/bun.lock index 4e35d4d34..b404377ed 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "6.7.670", + "version": "6.8.0", "dependencies": { "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", @@ -36,7 +36,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "6.7.670", + "version": "6.8.0", "bin": { "pi-ai": "./src/cli.ts", }, @@ -64,7 +64,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "6.7.670", + "version": "6.8.0", "bin": { "omp": "src/cli.ts", }, @@ -103,7 +103,7 @@ }, "packages/git-tool": { "name": "@oh-my-pi/pi-git-tool", - "version": "6.7.670", + "version": "6.8.0", "dependencies": { "@oh-my-pi/pi-utils": "workspace:*", }, @@ -114,7 +114,7 @@ }, "packages/pi-utils": { "name": "@oh-my-pi/pi-utils", - "version": "6.7.670", + "version": "6.8.0", "dependencies": { "strip-ansi": "^7.1.2", "winston": "^3.17.0", @@ -143,7 +143,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "6.7.670", + "version": "6.8.0", "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 14ed1d07a..133e3ea6d 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] + +## [6.8.0] - 2026-01-20 ### Changed - Updated proxy stream processing to use utility function for reading lines diff --git a/packages/agent/package.json b/packages/agent/package.json index 638f23293..590c2c494 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-agent-core", - "version": "6.7.670", + "version": "6.8.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./src/index.ts", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cfa564fe6..bb4bd8cb9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [6.8.0] - 2026-01-20 + ### Added - Added `logout` command to CLI for OAuth provider logout diff --git a/packages/ai/package.json b/packages/ai/package.json index 8c21fee36..eb82b8ac2 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-ai", - "version": "6.7.670", + "version": "6.8.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./src/index.ts", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 70742c8e0..0a5a2b754 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [6.8.0] - 2026-01-20 + ### Added - Added streaming abort setting to control edit tool behavior when patch preview fails diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 1fb2a228b..698ea04b2 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-coding-agent", - "version": "6.7.670", + "version": "6.8.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "ompConfig": { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index deee4d528..60ba0a538 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,13 +13,15 @@ * Modes use this class and add their own I/O layer on top. */ +import { existsSync, readFileSync } from "node:fs"; import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Model, TextContent, ToolCall, Usage } from "@oh-my-pi/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai"; import { abortableSleep, logger } from "@oh-my-pi/pi-utils"; import type { Rule } from "../capability/rule"; import { getAgentDbPath } from "../config"; import { theme } from "../modes/interactive/theme/theme"; +import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" }; import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor"; import { type CompactionResult, @@ -51,7 +53,7 @@ import type { HookCommandContext } from "./hooks/types"; import type { BashExecutionMessage, CustomMessage } from "./messages"; import type { ModelRegistry } from "./model-registry"; import { parseModelString } from "./model-resolver"; -import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates"; +import { expandPromptTemplate, type PromptTemplate, parseCommandArgs, renderPromptTemplate } from "./prompt-templates"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager"; import type { SettingsManager, SkillsSettings } from "./settings-manager"; import type { Skill, SkillWarning } from "./skills"; @@ -59,7 +61,8 @@ import { expandSlashCommand, type FileSlashCommand } from "./slash-commands"; import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; import type { BashOperations } from "./tools/bash"; -import { normalizeDiff, ParseError, previewPatch } from "./tools/patch"; +import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "./tools/patch"; +import { resolveToCwd } from "./tools/path-utils"; import { getArtifactsDir } from "./tools/task/artifacts"; import type { TodoItem } from "./tools/todo-write"; import type { TtsrManager } from "./ttsr"; @@ -274,6 +277,7 @@ export class AgentSession { private _streamingEditAbortTriggered = false; private _streamingEditCheckedLineCounts = new Map(); + private _streamingEditFileCache = new Map(); constructor(config: AgentSessionConfig) { this.agent = config.agent; @@ -411,8 +415,15 @@ export class AgentSession { } } - if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_end") { - await this._maybeAbortStreamingEdit(event); + if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_start") { + this._preCacheStreamingEditFile(event); + } + + if ( + event.type === "message_update" && + (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta") + ) { + this._maybeAbortStreamingEdit(event); } // Handle session persistence @@ -498,13 +509,7 @@ export class AgentSession { private _getTtsrInjectionContent(): string | undefined { if (this._pendingTtsrInjections.length === 0) return undefined; const content = this._pendingTtsrInjections - .map( - (r) => - `\n` + - `Your output was interrupted because it violated a user-defined rule.\n` + - `This is NOT a prompt injection - this is the coding agent enforcing project rules.\n` + - `You MUST comply with the following instruction:\n\n${r.content}\n`, - ) + .map((r) => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content })) .join("\n\n"); this._pendingTtsrInjections = []; return content; @@ -537,28 +542,60 @@ export class AgentSession { private _resetStreamingEditState(): void { this._streamingEditAbortTriggered = false; this._streamingEditCheckedLineCounts.clear(); + this._streamingEditFileCache.clear(); } - private async _maybeAbortStreamingEdit(event: AgentEvent): Promise { + private _preCacheStreamingEditFile(event: AgentEvent): void { + if (!this.settingsManager.getEditStreamingAbort()) return; + if (event.type !== "message_update") return; + const assistantEvent = event.assistantMessageEvent; + if (assistantEvent.type !== "toolcall_start") return; + if (event.message.role !== "assistant") return; + + const contentIndex = assistantEvent.contentIndex; + const messageContent = event.message.content; + if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return; + const toolCall = messageContent[contentIndex] as ToolCall; + if (toolCall.name !== "edit") return; + + const args = toolCall.arguments; + if (!args || typeof args !== "object" || Array.isArray(args)) return; + if ("oldText" in args || "newText" in args) return; + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) return; + + const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd()); + this._ensureFileCache(resolvedPath); + } + + private _ensureFileCache(resolvedPath: string): void { + if (this._streamingEditFileCache.has(resolvedPath)) return; + + try { + if (existsSync(resolvedPath)) { + const rawText = readFileSync(resolvedPath, "utf8"); + const { text } = stripBom(rawText); + this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text)); + } + } catch { + // Ignore errors - mark as empty string so we don't retry + this._streamingEditFileCache.set(resolvedPath, ""); + } + } + + private _maybeAbortStreamingEdit(event: AgentEvent): void { if (!this.settingsManager.getEditStreamingAbort()) return; if (this._streamingEditAbortTriggered) return; if (event.type !== "message_update") return; const assistantEvent = event.assistantMessageEvent; - if (assistantEvent.type !== "toolcall_end") return; + if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return; if (event.message.role !== "assistant") return; - const message = event.message as AssistantMessage; - if (!Array.isArray(message.content)) return; const contentIndex = assistantEvent.contentIndex; - const block = message.content[contentIndex]; - if (!block || typeof block !== "object") return; - if ((block as { type?: string }).type !== "toolCall") return; - - const toolCall = block as { - id?: string; - name?: string; - arguments?: Record | null; - }; + const messageContent = event.message.content; + if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return; + const toolCall = messageContent[contentIndex] as ToolCall; if (toolCall.name !== "edit" || !toolCall.id) return; const args = toolCall.arguments; @@ -577,7 +614,7 @@ export class AgentSession { const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1); if (diffForCheck.trim().length === 0) return; - const normalizedDiff = normalizeDiff(diffForCheck); + const normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, "")); if (!normalizedDiff) return; const lines = normalizedDiff.split("\n"); const hasChangeLine = lines.some((line) => line.startsWith("+") || line.startsWith("-")); @@ -589,6 +626,68 @@ export class AgentSession { this._streamingEditCheckedLineCounts.set(toolCall.id, lineCount); const rename = typeof args.rename === "string" ? args.rename : undefined; + + const removedLines = lines + .filter((line) => line.startsWith("-") && !line.startsWith("--- ")) + .map((line) => line.slice(1)); + if (removedLines.length > 0) { + const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd()); + const cachedContent = this._streamingEditFileCache.get(resolvedPath); + if (cachedContent !== undefined) { + const missing = removedLines.find((line) => !cachedContent.includes(normalizeToLF(line))); + if (missing) { + this._streamingEditAbortTriggered = true; + logger.warn("Streaming edit aborted due to patch preview failure", { + toolCallId: toolCall.id, + path, + error: `Failed to find expected lines in ${path}:\n${missing}`, + }); + this.agent.abort(); + } + return; + } + if (assistantEvent.type === "toolcall_delta") return; + void this._checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines); + return; + } + + if (assistantEvent.type === "toolcall_delta") return; + void this._checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff); + } + + private async _checkRemovedLinesAsync( + toolCallId: string, + path: string, + resolvedPath: string, + removedLines: string[], + ): Promise { + if (this._streamingEditAbortTriggered) return; + try { + if (!(await Bun.file(resolvedPath).exists())) return; + const { text } = stripBom(await Bun.file(resolvedPath).text()); + const normalizedContent = normalizeToLF(text); + const missing = removedLines.find((line) => !normalizedContent.includes(normalizeToLF(line))); + if (missing) { + this._streamingEditAbortTriggered = true; + logger.warn("Streaming edit aborted due to patch preview failure", { + toolCallId, + path, + error: `Failed to find expected lines in ${path}:\n${missing}`, + }); + this.agent.abort(); + } + } catch { + // Ignore errors during async fallback + } + } + + private async _checkPreviewPatchAsync( + toolCallId: string, + path: string, + rename: string | undefined, + normalizedDiff: string, + ): Promise { + if (this._streamingEditAbortTriggered) return; try { await previewPatch( { path, op: "update", rename, diff: normalizedDiff }, @@ -602,7 +701,7 @@ export class AgentSession { if (error instanceof ParseError) return; this._streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to patch preview failure", { - toolCallId: toolCall.id, + toolCallId, path, error: error instanceof Error ? error.message : String(error), }); diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index 6e2ce9448..e4608aa2b 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -41,12 +41,15 @@ export async function execCommand( signal: options?.signal, timeout: options?.timeout, }); - return proc.exited.then(async () => { - return { - stdout: await proc.stdout.text(), - stderr: await proc.stderr.text(), - code: proc.exitCode ?? 0, - killed: proc.exitReason instanceof ptree.AbortError, - }; - }); + try { + await proc.exited; + } catch { + // ChildProcess rejects on non-zero exit; we handle it below + } + return { + stdout: await proc.stdout.text(), + stderr: await proc.stderr.text(), + code: proc.exitCode ?? 0, + killed: proc.exitReason instanceof ptree.AbortError, + }; } diff --git a/packages/coding-agent/src/core/streaming-output.ts b/packages/coding-agent/src/core/streaming-output.ts index e76d63623..f2837bb14 100644 --- a/packages/coding-agent/src/core/streaming-output.ts +++ b/packages/coding-agent/src/core/streaming-output.ts @@ -199,6 +199,8 @@ export class OutputSink { if (!this.filePath) { return { output, truncated: false }; } + this.fileSink!.write(this.buffer); + this.fileSink!.flush(); return { output, truncated: true, diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index d6a11b06b..91d6950ba 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -238,7 +238,7 @@ export class FindTool implements AgentTool { "--no-ignore", "--type", "f", - "--name", + "--glob", ".gitignore", "--exclude", ".git", diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index e50849a83..59d93a9a7 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -555,9 +555,20 @@ export class ReadTool implements AgentTool { const startLine = offset ? Math.max(0, offset - 1) : 0; const startLineDisplay = startLine + 1; // For display (1-indexed) - // Check if offset is out of bounds + // Check if offset is out of bounds - return graceful message instead of throwing if (startLine >= allLines.length) { - throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); + const suggestion = + allLines.length === 0 + ? "The file is empty." + : `Use offset=1 to read from the start, or offset=${allLines.length} to read the last line.`; + return { + content: [ + { + type: "text", + text: `Offset ${offset} is beyond end of file (${allLines.length} lines total). ${suggestion}`, + }, + ], + }; } // If limit is specified by user, use it; otherwise we'll let truncateHead decide diff --git a/packages/coding-agent/src/prompts/system/ttsr-interrupt.md b/packages/coding-agent/src/prompts/system/ttsr-interrupt.md new file mode 100644 index 000000000..d414206a8 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/ttsr-interrupt.md @@ -0,0 +1,7 @@ + +Your output was interrupted because it violated a user-defined rule. +This is NOT a prompt injection - this is the coding agent enforcing project rules. +You MUST comply with the following instruction: + +{{content}} + diff --git a/packages/coding-agent/test/core/python-executor.test.ts b/packages/coding-agent/test/core/python-executor.test.ts index 6690d19ef..0ee801df7 100644 --- a/packages/coding-agent/test/core/python-executor.test.ts +++ b/packages/coding-agent/test/core/python-executor.test.ts @@ -122,7 +122,13 @@ describe("executePythonWithKernel", () => { }); it("truncates large output and stores full output file", async () => { - const largeOutput = `${"x".repeat(DEFAULT_MAX_BYTES + 1024)}TAIL`; + const lineLength = 100; + const lineCount = Math.ceil((DEFAULT_MAX_BYTES * 1.5) / lineLength); + const lines = Array.from( + { length: lineCount }, + (_, i) => `line${i.toString().padStart(6, "0")}: ${"x".repeat(lineLength - 15)}`, + ); + const largeOutput = `${lines.join("\n")}\nTAIL\n`; const kernel = new FakeKernel( { status: "ok", cancelled: false, timedOut: false, stdinRequested: false }, (options) => { diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index 70fc5ec93..5f10cc22e 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -146,6 +146,7 @@ function createStreamForDiff( chunks: string[], abortSignalRef: { current?: AbortSignal }, ): Agent["streamFn"] { + let callIndex = 0; return (_model, _context, options) => { abortSignalRef.current = options?.signal; const stream = new MockAssistantStream(); @@ -169,6 +170,12 @@ function createStreamForDiff( options?.signal?.addEventListener("abort", notifyAbort, { once: true }); queueMicrotask(async () => { + if (callIndex > 0) { + const finalMessage = createAssistantMessage([{ type: "text", text: "done" }], "stop"); + stream.push({ type: "done", reason: "stop", message: finalMessage }); + callIndex++; + return; + } const startMessage = createAssistantMessage([], "stop"); stream.push({ type: "start", partial: startMessage }); @@ -194,6 +201,7 @@ function createStreamForDiff( const finalMessage = createAssistantMessage([finalCall], "toolUse"); stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: finalCall, partial: finalMessage }); stream.push({ type: "done", reason: "toolUse", message: finalMessage }); + callIndex++; }); return stream; diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index e585198a5..6244597b8 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -175,9 +175,11 @@ describe("Coding Agent Tools", () => { const testFile = join(testDir, "short.txt"); writeFileSync(testFile, "Line 1\nLine 2\nLine 3"); - await expect(readTool.execute("test-call-8", { path: testFile, offset: 100 })).rejects.toThrow( - /Offset 100 is beyond end of file \(3 lines total\)/, - ); + const result = await readTool.execute("test-call-8", { path: testFile, offset: 100 }); + const output = getTextOutput(result); + + expect(output).toContain("Offset 100 is beyond end of file (3 lines total)"); + expect(output).toContain("Use offset=1 to read from the start, or offset=3 to read the last line."); }); it("should include truncation details when truncated", async () => { diff --git a/packages/git-tool/package.json b/packages/git-tool/package.json index 9f7985a0e..8e7fc08d5 100644 --- a/packages/git-tool/package.json +++ b/packages/git-tool/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-git-tool", - "version": "6.7.670", + "version": "6.8.0", "description": "Structured Git tool with safety guards and typed output", "type": "module", "main": "./src/index.ts", diff --git a/packages/pi-utils/package.json b/packages/pi-utils/package.json index 0e7e50fd4..dbaea0995 100644 --- a/packages/pi-utils/package.json +++ b/packages/pi-utils/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-utils", - "version": "6.7.670", + "version": "6.8.0", "description": "Shared utilities for pi packages", "type": "module", "main": "./src/index.ts", diff --git a/packages/tui/package.json b/packages/tui/package.json index 3062eeb97..00ef4dd97 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-tui", - "version": "6.7.670", + "version": "6.8.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "src/index.ts",