fix(daemon): keep question-form close-tag scan Unicode-safe in the daemon mirror

The mirrored renderable-form check lowercased the whole buffer before
indexing the close tag. Some code points expand under toLowerCase ('İ' ->
'i̇'), shifting every offset after them, so closeIdx desynced from openEnd
(original-string coordinate) and the JSON body slice was corrupted —
failing a valid form when a casing-expanding char preceded it.

Mirror the web parser's findCloseTag: scan char-by-char and lowercase each
fixed-length candidate slice, returning an index in the original-string
space. Added a daemon regression with an 'İ' preamble before a valid form.

Addresses Looper review feedback on #3640.
This commit is contained in:
lefarcen
2026-06-04 19:15:57 +08:00
parent ba28f07edd
commit 3207d1dc2b
2 changed files with 80 additions and 2 deletions
+18 -2
View File
@@ -2573,6 +2573,23 @@ function questionFormBodyIsRenderable(body) {
return Array.isArray(questions) && questions.some((q) => q && typeof q === 'object');
}
// Locate `closeTag` (case-insensitively) at or after `from`, returning an
// index in the ORIGINAL `text` coordinate space. Mirrors the web parser's
// `findCloseTag`: scanning char-by-char and lowercasing each fixed-length
// candidate slice keeps the result aligned with `openEnd`. Lowercasing the
// whole string up front instead would desync the index, because some code
// points expand under `toLowerCase()` (e.g. `"İ" -> "i̇"`) and shift every
// offset after them — corrupting the body slice and failing a valid form.
function findQuestionFormCloseTag(text, from, closeTag) {
const closeLower = closeTag.toLowerCase();
const tagLen = closeTag.length;
const maxStart = text.length - tagLen;
for (let i = from; i <= maxStart; i++) {
if (text.slice(i, i + tagLen).toLowerCase() === closeLower) return i;
}
return -1;
}
// Whether the agent's visible text contains a *renderable* clarifying form —
// a closed `<question-form>`/`<ask-question>` block whose body satisfies the
// parser contract above. The plugin-authoring missing-artifacts guard treats
@@ -2586,7 +2603,6 @@ function questionFormBodyIsRenderable(body) {
// `packages/contracts` if the two drift.
function emittedRenderableQuestionForm(text) {
if (typeof text !== 'string' || !text) return false;
const lower = text.toLowerCase();
let cursor = 0;
while (cursor < text.length) {
const m = QUESTION_FORM_OPEN_RE.exec(text.slice(cursor));
@@ -2594,7 +2610,7 @@ function emittedRenderableQuestionForm(text) {
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openEnd = cursor + m.index + m[0].length;
const closeIdx = lower.indexOf(closeTag, openEnd);
const closeIdx = findQuestionFormCloseTag(text, openEnd, closeTag);
if (closeIdx === -1) return false;
if (questionFormBodyIsRenderable(text.slice(openEnd, closeIdx))) return true;
cursor = closeIdx + closeTag.length;
+62
View File
@@ -727,6 +727,68 @@ process.stdin.on('end', () => {
},
);
});
it('does not fail plugin authoring when a valid form follows a Unicode preamble that expands under toLowerCase', async () => {
// The mirrored close-tag scan must stay in the original-string coordinate
// space. Some code points expand under toLowerCase ("İ" -> "i̇"), so
// lowercasing the whole buffer before indexing would desync the close-tag
// offset and corrupt the JSON body slice, failing a valid form. The
// preamble here contains "İ" before a well-formed form block.
const projectId = `proj-plugin-authoring-unicode-${randomUUID()}`;
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Plugin authoring unicode-preamble fixture',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResponse.status).toBe(200);
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
expect(conversationsResponse.status).toBe(200);
const conversationsBody = await conversationsResponse.json() as {
conversations: Array<{ id: string }>;
};
const conversationId = conversationsBody.conversations[0]?.id;
expect(conversationId).toBeTruthy();
await withFakeAgent(
'opencode',
`
process.stdin.resume();
process.stdin.on('end', () => {
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: 'İstanbul brief — 先确认几个问题。\\n<ask-question id="discovery" title="Plugin brief">\\n{"questions":[{"id":"purpose","label":"What should it do?","type":"text"}]}\\n</ask-question>' } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
conversationId,
pluginId: 'od-plugin-authoring',
message: '帮我做个插件。',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).not.toContain('ended before generating the required generated-plugin artifacts');
expect(statusBody.status).toBe('succeeded');
},
);
});
it('closes the # Instructions block with an explicit "do not echo" guard so models do not parrot the prompt back', async () => {
// claude-opus-4-7 (and a few other instruction-tuned models) start
// their reply by echoing the # Instructions block verbatim, which