mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user