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
@@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createHashlineReadEnhancerHook } from "./hook"
@@ -50,6 +52,38 @@ describe("hashline-read-enhancer", () => {
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 () => {
//#given
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)")
})
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 () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })