Skip to content

Commit b8990f0

Browse files
committed
chore: merge v2
2 parents c620b19 + 1dcc655 commit b8990f0

6 files changed

Lines changed: 205 additions & 6 deletions

File tree

packages/core/src/config/plugin/command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ function evaluateTemplate(
206206
if (position === last) return args.slice(argIndex).join(" ")
207207
return args[argIndex]
208208
})
209-
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
209+
const withArguments = expanded.replaceAll("$ARGUMENTS", () => input)
210210
const text =
211211
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
212212
? `${withArguments}\n\n${input}`.trim()

packages/core/src/tool/html-markdown.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,20 @@ export function convertHTMLToMarkdown(html: string) {
217217
}
218218
if (code.inline) {
219219
const fence = "`".repeat(Math.max(1, backticks + 1))
220-
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
221220
flushSpace()
222221
prefixQuote()
223-
const wrapper = encoder.encode(`${fence}${padding}${padding}${fence}`).byteLength
224-
appendRaw(
225-
`${fence}${padding}${sliceBytes(code.text, Math.max(0, CONTENT_BYTES - outputBytes - wrapper))}${padding}${fence}`,
226-
)
222+
const available = Math.max(0, CONTENT_BYTES - outputBytes - fence.length * 2)
223+
let payload = sliceBytes(code.text, available)
224+
while (payload) {
225+
const padding = /^[ `]|[ `]$/.test(payload) && !/^ +$/.test(payload) ? " " : ""
226+
const bytes = encoder.encode(payload).byteLength
227+
if (bytes + padding.length * 2 <= available) {
228+
appendRaw(`${fence}${padding}${payload}${padding}${fence}`)
229+
return
230+
}
231+
// Padding costs at most two bytes, so at most two whole-code-point trims are needed.
232+
payload = sliceBytes(payload, bytes - 1)
233+
}
227234
return
228235
}
229236
if (activeCell) {
@@ -246,6 +253,8 @@ export function convertHTMLToMarkdown(html: string) {
246253
block()
247254
return
248255
}
256+
// No amount of trimming can make the empty fenced block fit.
257+
if (!payload) return
249258
const excess = valueBytes - Math.max(0, CONTENT_BYTES - outputBytes)
250259
payload = sliceBytes(payload, Math.max(0, encoder.encode(payload).byteLength - Math.ceil(excess)))
251260
}

packages/core/test/config/command.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,73 @@ const it = testEffect(
7171
const decode = Schema.decodeUnknownSync(Info)
7272

7373
describe("ConfigCommandPlugin.Plugin", () => {
74+
for (const item of [
75+
...["$&", "$$", "$`", "$'"].flatMap((input) => [
76+
{ template: "Explain $ARGUMENTS.", input, expected: `Explain ${input}.` },
77+
{ template: "Explain $1.", input: `"${input}"`, expected: `Explain ${input}.` },
78+
{ template: "Explain.", input, expected: `Explain.\n\n${input}` },
79+
]),
80+
...["abc", "", "alpha beta", '"alpha beta"', "$1", "$<name>"].map((input) => ({
81+
template: "Explain $ARGUMENTS.",
82+
input,
83+
expected: `Explain ${input}.`,
84+
})),
85+
{
86+
template: "First $1. Rest $2.",
87+
input: '"alpha beta" gamma delta',
88+
expected: "First alpha beta. Rest gamma delta.",
89+
},
90+
{ template: "$ARGUMENTS / $ARGUMENTS", input: "$& $$", expected: "$& $$ / $& $$" },
91+
]) {
92+
it.live(`interpolates ${JSON.stringify(item.template)} with literal input ${JSON.stringify(item.input)}`, () =>
93+
Effect.gen(function* () {
94+
const command = yield* Command.Service
95+
const prompts: { text: string; delivery?: string }[] = []
96+
yield* ConfigCommandPlugin.Plugin.effect(
97+
host({
98+
command: {
99+
list: () => Effect.die(new Error("unused command.list")),
100+
transform: command.transform,
101+
reload: command.reload,
102+
},
103+
session: {
104+
prompt: (input) =>
105+
Effect.sync(() => {
106+
prompts.push({ text: input.text, delivery: input.delivery })
107+
return SessionInbox.User.make({
108+
id: SessionMessage.ID.make("msg_test"),
109+
sessionID: input.sessionID,
110+
timeCreated: DateTime.makeUnsafe(0),
111+
type: "user",
112+
payload: { text: input.text },
113+
delivery: input.delivery ?? "steer",
114+
})
115+
}),
116+
},
117+
}),
118+
).pipe(
119+
Effect.provide(
120+
Config.testLayer([
121+
new Document({
122+
type: "document",
123+
info: decode({ commands: { explain: { template: item.template } } }),
124+
}),
125+
]),
126+
),
127+
)
128+
yield* command.execute({
129+
name: "explain",
130+
invocation: {
131+
sessionID: Session.ID.make("ses_test"),
132+
prompt: { text: item.input },
133+
delivery: "queue",
134+
},
135+
})
136+
expect(prompts).toEqual([{ text: item.expected, delivery: "queue" }])
137+
}),
138+
)
139+
}
140+
74141
it.live("loads inline and file-based commands in config order", () =>
75142
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
76143
Effect.flatMap((tmp) =>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { writeSync } from "node:fs"
2+
import { convertHTMLToMarkdown } from "../../src/tool/html-markdown"
3+
4+
const html = await Bun.stdin.text()
5+
// Flush readiness before entering a conversion that may block the child event loop.
6+
writeSync(1, "ready\n")
7+
await Bun.write(Bun.stdout, convertHTMLToMarkdown(html))
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { expect, test } from "bun:test"
2+
import { fileURLToPath } from "node:url"
3+
import { MAX_MARKDOWN_BYTES } from "../src/tool/html-markdown"
4+
5+
const budget = MAX_MARKDOWN_BYTES - 64 * 1024
6+
7+
test.each([
8+
["exhausted budget", budget, "x", "", false],
9+
["one byte short of an empty fence", budget - 13, "x", "", false],
10+
["small fitting block", 64, "x", "\n\n```\nx\n```", false],
11+
["last payload byte fits", budget - 15, "x", "\n\n```\nx\n```", false],
12+
["only an empty fence fits", budget - 14, "x", "\n\n```\n\n```", false],
13+
["Unicode payload truncates at a code point", budget - 18, "😀é", "\n\n```\n😀\n```", false],
14+
["quoted payload fits", budget - 23, "x", "\n\n> ```\n> x\n> ```", true],
15+
["only an empty quoted fence fits", budget - 22, "x", "\n\n> ```\n> \n> ```", true],
16+
["one byte short of an empty quoted fence", budget - 21, "x", "", true],
17+
] as const)(
18+
"finishes bounded code conversion: %s",
19+
async (_name, count, payload, suffix, quoted) => {
20+
const code = `<pre>${payload}</pre>`
21+
const html = `<p>${"x".repeat(count)}</p>${quoted ? `<blockquote>${code}</blockquote>` : code}`
22+
expect(Buffer.byteLength(html)).toBeLessThanOrEqual(MAX_MARKDOWN_BYTES)
23+
const child = Bun.spawn({
24+
cmd: [process.execPath, fileURLToPath(new URL("./fixture/html-markdown.ts", import.meta.url))],
25+
stdin: new Blob([html]),
26+
stdout: "pipe",
27+
stderr: "pipe",
28+
})
29+
let ready = false
30+
let timeout: "startup" | "conversion" | undefined
31+
let timer = setTimeout(() => {
32+
timeout = "startup"
33+
child.kill("SIGKILL")
34+
}, 10_000)
35+
const stdout = (async () => {
36+
let output = ""
37+
for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) {
38+
output += chunk
39+
if (ready || !output.startsWith("ready\n")) continue
40+
ready = true
41+
clearTimeout(timer)
42+
// This watchdog runs outside the possibly stuck synchronous converter.
43+
timer = setTimeout(() => {
44+
timeout = "conversion"
45+
child.kill("SIGKILL")
46+
}, 3_000)
47+
}
48+
return output.slice("ready\n".length)
49+
})()
50+
const stderr = new Response(child.stderr).text()
51+
try {
52+
const exitCode = await child.exited
53+
const output = await stdout
54+
expect({ ready, timeout, exitCode, stderr: await stderr }).toEqual({
55+
ready: true,
56+
timeout: undefined,
57+
exitCode: 0,
58+
stderr: "",
59+
})
60+
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(MAX_MARKDOWN_BYTES)
61+
expect(output).toBe("x".repeat(Math.min(count, budget - 2)) + suffix)
62+
} finally {
63+
clearTimeout(timer)
64+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
65+
await child.exited
66+
}
67+
},
68+
15_000,
69+
)

packages/core/test/tool-webfetch.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,53 @@ describe("WebFetchTool helpers", () => {
9090
)
9191
})
9292

93+
test.each([
94+
["`x`", "`` `x` ``"],
95+
["`x", "`` `x ``"],
96+
["x`", "`` x` ``"],
97+
["`", "`` ` ``"],
98+
["``", "``` `` ```"],
99+
["``x`", "``` ``x` ```"],
100+
["say(`x`)", "``say(`x`)``"],
101+
["a``b`c", "```a``b`c```"],
102+
["x", "`x`"],
103+
[" x ", "` x `"],
104+
[" x", "` x `"],
105+
["x ", "` x `"],
106+
[" ", "` `"],
107+
[" ` ", "`` ` ``"],
108+
])("preserves inline code boundaries for %j", (content, expected) => {
109+
expect(WebFetchTool.convertHTMLToMarkdown(`<p>Use <code>${content}</code>.</p>`)).toBe(`Use ${expected}.`)
110+
})
111+
112+
test.each([
113+
["discarded trailing backtick after ASCII", "x`", 7, "``x``"],
114+
["discarded trailing backtick after Unicode", "😀`", 10, "``😀``"],
115+
["discarded trailing backtick with spare room", "x`", 9, "``x``"],
116+
["retained trailing backtick", "x`", 10, "`` x` ``"],
117+
["new trailing backtick from an internal run", "x`y", 8, "``x``"],
118+
["leading backtick without padding room", "`x", 8, ""],
119+
["leading backtick alone fits", "`x", 9, "`` ` ``"],
120+
["leading backtick with payload fits", "`x", 10, "`` `x ``"],
121+
["all backticks truncated", "``", 11, "``` ` ```"],
122+
["all backticks fit", "``", 12, "``` `` ```"],
123+
["mixed internal runs truncated", "a``b`c", 11, "```a```"],
124+
["Unicode code point cannot fit", "😀`", 9, ""],
125+
["ordinary payload cannot fit", "x", 3, ""],
126+
["spaces cannot fit", " ", 4, ""],
127+
["space-only prefix fits", " ", 5, "` `"],
128+
["truncated prefix becomes space-only", " x", 6, "` `"],
129+
["discarded trailing space", "x ", 5, "`x`"],
130+
] as const)("fits inline code to its emitted boundaries: %s", (_name, content, spare, expected) => {
131+
const prefix = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024 - spare)
132+
const html = `<p>${prefix}<code>${content}</code></p>`
133+
expect(Buffer.byteLength(html)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
134+
const output = WebFetchTool.convertHTMLToMarkdown(html)
135+
expect(output.slice(0, prefix.length)).toBe(prefix)
136+
expect(output.slice(prefix.length)).toBe(expected)
137+
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
138+
})
139+
93140
test("keeps nested ordered and unordered lists structurally readable", () => {
94141
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
95142
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(

0 commit comments

Comments
 (0)