fix(hashline-read-enhancer): handle inline <content> tag from updated OpenCode read tool

OpenCode updated its read tool output format — the <content> tag now shares
a line with the first content line (<content>1: content) with no newline.

The hook's exact indexOf('<content>') detection returned -1, causing all
read output to pass through unmodified (no hash anchors). This silently
disabled the entire hashline-edit workflow.

Fixes:
- Sub-bug 1: Use findIndex + startsWith instead of exact indexOf match
- Sub-bug 2: Extract inline content after <content> prefix as first line
- Sub-bug 3: Normalize open-tag line to bare tag in output (no duplicate)

Also adds backward compat for legacy <file> + 00001| pipe format.
This commit is contained in:
minpeter 2026-02-24 05:47:05 +09:00
parent 08b663df86
commit 1cb362773b
2 changed files with 140 additions and 14 deletions

View File

@ -6,9 +6,12 @@ interface HashlineReadEnhancerConfig {
hashline_edit?: { enabled: boolean } hashline_edit?: { enabled: boolean }
} }
const READ_LINE_PATTERN = /^(\d+): ?(.*)$/ const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/
const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/
const CONTENT_OPEN_TAG = "<content>" const CONTENT_OPEN_TAG = "<content>"
const CONTENT_CLOSE_TAG = "</content>" const CONTENT_CLOSE_TAG = "</content>"
const FILE_OPEN_TAG = "<file>"
const FILE_CLOSE_TAG = "</file>"
function isReadTool(toolName: string): boolean { function isReadTool(toolName: string): boolean {
return toolName.toLowerCase() === "read" return toolName.toLowerCase() === "read"
@ -24,18 +27,36 @@ function shouldProcess(config: HashlineReadEnhancerConfig): boolean {
function isTextFile(output: string): boolean { function isTextFile(output: string): boolean {
const firstLine = output.split("\n")[0] ?? "" const firstLine = output.split("\n")[0] ?? ""
return READ_LINE_PATTERN.test(firstLine) return COLON_READ_LINE_PATTERN.test(firstLine) || PIPE_READ_LINE_PATTERN.test(firstLine)
}
function parseReadLine(line: string): { lineNumber: number; content: string } | null {
const colonMatch = COLON_READ_LINE_PATTERN.exec(line)
if (colonMatch) {
return {
lineNumber: Number.parseInt(colonMatch[1], 10),
content: colonMatch[2],
}
}
const pipeMatch = PIPE_READ_LINE_PATTERN.exec(line)
if (pipeMatch) {
return {
lineNumber: Number.parseInt(pipeMatch[1], 10),
content: pipeMatch[2],
}
}
return null
} }
function transformLine(line: string): string { function transformLine(line: string): string {
const match = READ_LINE_PATTERN.exec(line) const parsed = parseReadLine(line)
if (!match) { if (!parsed) {
return line return line
} }
const lineNumber = parseInt(match[1], 10) const hash = computeLineHash(parsed.lineNumber, parsed.content)
const content = match[2] return `${parsed.lineNumber}#${hash}:${parsed.content}`
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}:${content}`
} }
function transformOutput(output: string): string { function transformOutput(output: string): string {
@ -44,25 +65,43 @@ function transformOutput(output: string): string {
} }
const lines = output.split("\n") const lines = output.split("\n")
const contentStart = lines.indexOf(CONTENT_OPEN_TAG) const contentStart = lines.findIndex(
(line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG)
)
const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG) const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG)
const fileStart = lines.findIndex((line) => line === FILE_OPEN_TAG || line.startsWith(FILE_OPEN_TAG))
const fileEnd = lines.indexOf(FILE_CLOSE_TAG)
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart + 1) { const blockStart = contentStart !== -1 ? contentStart : fileStart
const fileLines = lines.slice(contentStart + 1, contentEnd) const blockEnd = contentStart !== -1 ? contentEnd : fileEnd
const openTag = contentStart !== -1 ? CONTENT_OPEN_TAG : FILE_OPEN_TAG
if (blockStart !== -1 && blockEnd !== -1 && blockEnd > blockStart) {
const openLine = lines[blockStart] ?? ""
const inlineFirst = openLine.startsWith(openTag) && openLine !== openTag
? openLine.slice(openTag.length)
: null
const fileLines = inlineFirst !== null
? [inlineFirst, ...lines.slice(blockStart + 1, blockEnd)]
: lines.slice(blockStart + 1, blockEnd)
if (!isTextFile(fileLines[0] ?? "")) { if (!isTextFile(fileLines[0] ?? "")) {
return output return output
} }
const result: string[] = [] const result: string[] = []
for (const line of fileLines) { for (const line of fileLines) {
if (!READ_LINE_PATTERN.test(line)) { if (!parseReadLine(line)) {
result.push(...fileLines.slice(result.length)) result.push(...fileLines.slice(result.length))
break break
} }
result.push(transformLine(line)) result.push(transformLine(line))
} }
return [...lines.slice(0, contentStart + 1), ...result, ...lines.slice(contentEnd)].join("\n") const prefixLines = inlineFirst !== null
? [...lines.slice(0, blockStart), openTag]
: lines.slice(0, blockStart + 1)
return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n")
} }
if (!isTextFile(lines[0] ?? "")) { if (!isTextFile(lines[0] ?? "")) {
@ -71,7 +110,7 @@ function transformOutput(output: string): string {
const result: string[] = [] const result: string[] = []
for (const line of lines) { for (const line of lines) {
if (!READ_LINE_PATTERN.test(line)) { if (!parseReadLine(line)) {
result.push(...lines.slice(result.length)) result.push(...lines.slice(result.length))
break break
} }

View File

@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test" import { describe, it, expect } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { createHashlineReadEnhancerHook } from "./hook" import { createHashlineReadEnhancerHook } from "./hook"
@ -50,6 +52,38 @@ describe("hashline-read-enhancer", () => {
expect(lines[10]).toBe("1: keep this unchanged") expect(lines[10]).toBe("1: keep this unchanged")
}) })
it("hashifies inline <content> format from updated OpenCode read tool", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<path>/tmp/demo.ts</path>",
"<type>file</type>",
"<content>1: const x = 1",
"2: const y = 2",
"",
"(End of file - total 2 lines)",
"</content>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[0]).toBe("<path>/tmp/demo.ts</path>")
expect(lines[1]).toBe("<type>file</type>")
expect(lines[2]).toBe("<content>")
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
expect(lines[6]).toBe("(End of file - total 2 lines)")
expect(lines[7]).toBe("</content>")
})
it("hashifies plain read output without content tags", async () => { it("hashifies plain read output without content tags", async () => {
//#given //#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@ -77,6 +111,59 @@ describe("hashline-read-enhancer", () => {
expect(lines[4]).toBe("(End of file - total 3 lines)") expect(lines[4]).toBe("(End of file - total 3 lines)")
}) })
it("hashifies read output with <file> and zero-padded pipe format", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
"00001| const x = 1",
"00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
expect(lines[5]).toBe("</file>")
})
it("hashifies pipe format even with leading spaces", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
" 00001| const x = 1",
" 00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/)
})
it("appends LINE#ID output for write tool using metadata filepath", async () => { it("appends LINE#ID output for write tool using metadata filepath", async () => {
//#given //#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })