mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
441ab84b0b
* fix(web): hide an od-card whose JSON does not parse A closed `<od-card>…</od-card>` block whose payload fails to parse was painted to the user as prose — Markdown and all, so `user_profile` came out italic. A tail comma, a missing `summary`, a misspelled `type`: the model writes all three, and each one put raw protocol markup on screen. Product ruling (user, 2026-09-18): "od-card 如果 json 不对, 就不显示, 不然用户会觉得是乱码...还不如不显示". The block is now dropped instead. The drop lives in the web render helper `splitShellCards`, not in the shared `splitOnOdCards` parser: that parser is a lossless split whose other callers (`chat-protocol-context` Markdown skip-ranges, daemon `memory-verify`) read spans of the original text and need every character back. Both chat surfaces — shell narration (`SayBlock`) and prose (`AssistantMessage`) — already route through `splitShellCards`. "Malformed" and "still streaming" stay separate states: dropping needs a matched close tag, so a card mid-flight is still withheld and can still complete into a real card. A fenced code block quoting the protocol is still user prose, and a valid card still renders as `OdCardView`. Refs OPEND-2745 * fix(web): classify Markdown over the text a dropped card leaves behind Dropping a malformed block without recomputing the Markdown context opened a second leak of exactly the kind this change exists to close. `codeRanges` was still derived from the raw input, so markup inside a payload the user never sees kept voting: a payload carrying an unclosed ``` fence on its own line marked everything after it as code, the next perfectly valid card was skipped by `rangeContains`, and the tail `appendText` emitted that valid card as raw `<od-card …>` markup. Markdown context is now classified over the text that will actually be rendered — the prose retained so far in the current render plus the unconsumed suffix — and positions are mapped into that view. Recomputing from the suffix alone would be wrong in the other direction. A dropped block is a hole in one continuous render, not a break in it: the prose before and after it is handed to Markdown as a single string, so the prose before the drop must keep its say over what follows. Judged on the suffix alone, a ``` that sits mid-line in the real render becomes a line-leading fence that hides everything below it — the same leak again. A rendered card, by contrast, really does end the render, so that branch keeps resetting as before. Refs OPEND-2745
120 lines
6.0 KiB
TypeScript
120 lines
6.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { splitShellCards } from '../../../src/runtime/chat/split-shell-cards';
|
|
|
|
const payload = { summary: 'Preserve `<od-demo>text</od-demo>`', fields: [] };
|
|
const card = `<od-card type="task-brief">${JSON.stringify(payload)}</od-card>`;
|
|
|
|
describe('shell card decoding', () => {
|
|
it('preserves a different tag sharing the od-card prefix while streaming', () => {
|
|
const text = 'Before <od-card-example> keep this normal explanation.';
|
|
expect(splitShellCards(text, true)).toEqual([{ kind: 'text', text }]);
|
|
});
|
|
|
|
it('keeps Markdown-like content inside a real card payload intact', () => {
|
|
expect(splitShellCards(card, false)).toEqual([
|
|
{ kind: 'card', card: { kind: 'task-brief', ...payload }, raw: card },
|
|
]);
|
|
});
|
|
|
|
it('does not let a backtick inside one card payload turn the next card into code', () => {
|
|
const firstPayload = { summary: 'Use `brand', fields: [] };
|
|
const secondPayload = { summary: 'Applied palette', used: [{ type: 'rule', name: 'Palette' }] };
|
|
const first = `<od-card type="task-brief">${JSON.stringify(firstPayload)}</od-card>`;
|
|
const second = `<od-card type="memory-applied">${JSON.stringify(secondPayload)}</od-card>`;
|
|
|
|
expect(splitShellCards(`${first}\n${second}\ntail\``, false)).toEqual([
|
|
{ kind: 'card', card: { kind: 'task-brief', ...firstPayload }, raw: first },
|
|
{ kind: 'text', text: '\n' },
|
|
{ kind: 'card', card: { kind: 'memory-applied', ...secondPayload }, raw: second },
|
|
{ kind: 'text', text: '\ntail`' },
|
|
]);
|
|
});
|
|
|
|
it('does not let an unclosed tag quoted in code consume a later real card', () => {
|
|
const quoted = '`<od-card type="task-brief">`\n\n';
|
|
const segments = splitShellCards(quoted + card, true);
|
|
expect(segments[0]).toEqual({ kind: 'text', text: quoted });
|
|
expect(segments[1]?.kind).toBe('card');
|
|
});
|
|
|
|
// Product ruling (user, 2026-09-18): a closed card whose JSON does not parse
|
|
// is not shown at all — raw protocol markup reads as garbage to the user.
|
|
it('drops a malformed complete block instead of painting it beside valid cards', () => {
|
|
const malformed = '<od-card type="task-brief">invalid JSON</od-card>';
|
|
const segments = splitShellCards(`${malformed}\n${card}`, false);
|
|
expect(segments).toEqual([
|
|
{ kind: 'text', text: '\n' },
|
|
{ kind: 'card', card: { kind: 'task-brief', ...payload }, raw: card },
|
|
]);
|
|
});
|
|
|
|
it('keeps the prose around a dropped malformed block', () => {
|
|
const malformed = '<od-card type="memory-applied">{"used":[],}</od-card>';
|
|
expect(splitShellCards(`Before.\n${malformed}\nAfter.`, false)).toEqual([
|
|
{ kind: 'text', text: 'Before.\n\nAfter.' },
|
|
]);
|
|
});
|
|
|
|
// "Malformed" is not "still being written": an opener with no close tag yet is
|
|
// withheld while streaming so a later delta can complete it into a real card.
|
|
it('withholds an unclosed opener while streaming instead of dropping the turn', () => {
|
|
const partial = 'Reading your preferences.\n\n<od-card type="memory-applied">{"sum';
|
|
expect(splitShellCards(partial, true)).toEqual([
|
|
{ kind: 'text', text: 'Reading your preferences.\n\n' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Markdown classification must be computed over the text that will actually be
|
|
* RENDERED. A dropped block contributes no characters to the render, so nothing
|
|
* inside its payload may decide whether a later `<od-card` opener counts as a
|
|
* quoted example. The view is "retained prose + unprocessed suffix" — neither
|
|
* the raw input (stale: the payload still votes) nor the suffix alone (lossy:
|
|
* the prose before the drop stops voting, and both halves render together).
|
|
*/
|
|
describe('shell card decoding · Markdown context around a dropped block', () => {
|
|
it('does not let a fence hidden in a dropped payload swallow the next valid card', () => {
|
|
// The payload carries an unclosed fence on its own line. Left in the
|
|
// classifier's view it marks everything after it as code, so the valid card
|
|
// below is never decoded and falls out of the tail `appendText` as raw
|
|
// markup — the exact leak this whole change exists to remove.
|
|
const malformed = '<od-card type="memory-applied">{"summary":"x",\n```\n"used":[],}</od-card>';
|
|
|
|
expect(splitShellCards(`${malformed}\n${card}`, false)).toEqual([
|
|
{ kind: 'text', text: '\n' },
|
|
{ kind: 'card', card: { kind: 'task-brief', ...payload }, raw: card },
|
|
]);
|
|
});
|
|
|
|
it('keeps a fenced protocol example quoted before a dropped block as prose', () => {
|
|
// Reverse anchor: recomputing the context must not start decoding cards the
|
|
// user only quoted. The fenced example stays raw; the malformed block after
|
|
// it is still dropped.
|
|
const quoted = '```html\n<od-card type="task-brief">{"summary":"Doc"}</od-card>\n```\n';
|
|
const malformed = '<od-card type="memory-applied">{"used":[],}</od-card>';
|
|
|
|
expect(splitShellCards(`${quoted}${malformed}\ntail`, false)).toEqual([
|
|
{ kind: 'text', text: `${quoted}\ntail` },
|
|
]);
|
|
});
|
|
|
|
it('keeps the retained prose in the view instead of reclassifying the suffix alone', () => {
|
|
// The prose before the drop does not end in a newline, so the ``` that
|
|
// follows the dropped block is mid-line and opens nothing. Classify the
|
|
// suffix on its own and that ``` becomes a line-leading fence that runs to
|
|
// the end of the buffer, hiding the valid card below it and leaking it as
|
|
// raw markup. Both halves render as one Markdown string, so both must vote.
|
|
// (A backtick-free payload here, so only the ``` decides the outcome.)
|
|
const malformed = '<od-card type="memory-applied">{"used":[],}</od-card>';
|
|
const plainPayload = { summary: 'Applied palette', used: [{ type: 'rule', name: 'Palette' }] };
|
|
const plain = `<od-card type="memory-applied">${JSON.stringify(plainPayload)}</od-card>`;
|
|
|
|
expect(splitShellCards(`Inline example: ${malformed}\`\`\`\nstill prose\n${plain}`, false))
|
|
.toEqual([
|
|
{ kind: 'text', text: 'Inline example: ```\nstill prose\n' },
|
|
{ kind: 'card', card: { kind: 'memory-applied', ...plainPayload }, raw: plain },
|
|
]);
|
|
});
|
|
});
|