| ... | @@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) { | ... | @@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) { |
| 226 | }; | 226 | }; |
| 227 | } | 227 | } |
| 228 | | 228 | |
| | 229 | function buildAssistantTimelineEntry(text: string) { |
| | 230 | const entry = buildUserTimelineEntry(text); |
| | 231 | return { |
| | 232 | ...entry, |
| | 233 | message: { |
| | 234 | ...entry.message, |
| | 235 | role: "assistant" as const, |
| | 236 | }, |
| | 237 | }; |
| | 238 | } |
| | 239 | |
| 229 | describe("MessagesTimeline", () => { | 240 | describe("MessagesTimeline", () => { |
| 230 | it("sizes expanded tool details with the configured code font size", () => { | 241 | it("sizes expanded tool details with the configured code font size", () => { |
| 231 | expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); | 242 | expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); |
| ... | @@ -470,7 +481,142 @@ describe("MessagesTimeline", () => { | ... | @@ -470,7 +481,142 @@ describe("MessagesTimeline", () => { |
| 470 | expect(markup).toContain("rounded-2xl bg-message p-3"); | 481 | expect(markup).toContain("rounded-2xl bg-message p-3"); |
| 471 | }); | 482 | }); |
| 472 | | 483 | |
| 473 | it("renders inline terminal labels with the composer chip UI", () => { | 484 | it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { |
| | 485 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| | 486 | const markup = renderToStaticMarkup( |
| | 487 | <MessagesTimeline |
| | 488 | {...buildProps()} |
| | 489 | timelineEntries={[ |
| | 490 | buildUserTimelineEntry( |
| | 491 | [ |
| | 492 | 'Without reading a file, do you have <global-agent-instructions scope="workspace">', |
| | 493 | 'Before <nested data-value="a&b">inside</nested> after', |
| | 494 | "</global-agent-instructions> in your context?", |
| | 495 | "Comparison: 2 < 3 and 5 > 4.", |
| | 496 | ].join("\n"), |
| | 497 | ), |
| | 498 | ]} |
| | 499 | />, |
| | 500 | ); |
| | 501 | |
| | 502 | expect(markup).toContain("&lt;global-agent-instructions scope=&quot;workspace&quot;&gt;"); |
| | 503 | expect(markup).toContain( |
| | 504 | "Before &lt;nested data-value=&quot;a&amp;b&quot;&gt;inside&lt;/nested&gt; after", |
| | 505 | ); |
| | 506 | expect(markup).toContain("&lt;/global-agent-instructions&gt; in your context?"); |
| | 507 | expect(markup).toContain("Comparison: 2 &lt; 3 and 5 &gt; 4."); |
| | 508 | }); |
| | 509 | |
| | 510 | it("preserves XML-like source inside user code spans and fences", async () => { |
| | 511 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| | 512 | const markup = renderToStaticMarkup( |
| | 513 | <MessagesTimeline |
| | 514 | {...buildProps()} |
| | 515 | timelineEntries={[ |
| | 516 | buildUserTimelineEntry( |
| | 517 | [ |
| | 518 | 'Inline `<tag attr="x">`', |
| | 519 | "", |
| | 520 | "```xml", |
| | 521 | '<root><child enabled="true" /></root>', |
| | 522 | "```", |
| | 523 | ].join("\n"), |
| | 524 | ), |
| | 525 | ]} |
| | 526 | />, |
| | 527 | ); |
| | 528 | |
| | 529 | expect(markup).toContain('<code data-inline-code="">&lt;tag attr=&quot;x&quot;&gt;</code>'); |
| | 530 | expect(markup).toContain("&lt;root&gt;&lt;child enabled=&quot;true&quot; /&gt;&lt;/root&gt;"); |
| | 531 | }); |
| | 532 | |
| | 533 | it("does not render markdown title attributes in user messages", async () => { |
| | 534 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| | 535 | const markup = renderToStaticMarkup( |
| | 536 | <MessagesTimeline |
| | 537 | {...buildProps()} |
| | 538 | timelineEntries={[ |
| | 539 | buildUserTimelineEntry( |
| | 540 | '[link](https://example.com "link tip") ', |
| | 541 | ), |
| | 542 | ]} |
| | 543 | />, |
| | 544 | ); |
| | 545 | |
| | 546 | expect(markup).toContain('href="https://example.com"'); |
| | 547 | expect(markup).toContain('src="https://example.com/image.png"'); |
| | 548 | expect(markup).not.toContain('title="link tip"'); |
| | 549 | expect(markup).not.toContain('title="image tip"'); |
| | 550 | }); |
| | 551 | |
| | 552 | it("renders unsafe user HTML as inert source text", async () => { |
| | 553 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| | 554 | const markup = renderToStaticMarkup( |
| | 555 | <MessagesTimeline |
| | 556 | {...buildProps()} |
| | 557 | timelineEntries={[ |
| | 558 | buildUserTimelineEntry( |
| | 559 | '<script>globalThis.__t3Xss = 1</script><img src="x" onerror="globalThis.__t3Xss = 2">', |
| | 560 | ), |
| | 561 | ]} |
| | 562 | />, |
| | 563 | ); |
| | 564 | |
| | 565 | expect(markup).toContain("&lt;script&gt;globalThis.__t3Xss = 1&lt;/script&gt;"); |
| | 566 | expect(markup).toContain( |
| | 567 | "&lt;img src=&quot;x&quot; onerror=&quot;globalThis.__t3Xss = 2&quot;&gt;", |
| | 568 | ); |
| | 569 | expect(markup).not.toMatch(/<script(?:\s|>)/i); |
| | 570 | expect(markup).not.toMatch(/<img(?:\s|>)/i); |
| | 571 | }); |
| | 572 | |
| | 573 | it("continues to render sanitized raw HTML in assistant messages", async () => { |
| | 574 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| | 575 | const markup = renderToStaticMarkup( |
| | 576 | <MessagesTimeline |
| | 577 | {...buildProps()} |
| | 578 | timelineEntries={[ |
| | 579 | buildAssistantTimelineEntry("<details><summary>More</summary>Details</details>"), |
| | 580 | ]} |
| | 581 | />, |
| | 582 | ); |
| | 583 | |
| | 584 | expect(markup).toContain('data-markdown-details=""'); |
| | 585 | expect(markup).toContain("More"); |
| | 586 | expect(markup).not.toContain("&lt;details&gt;"); |
| | 587 | }); |
| | 588 | |
| | 589 | it("sanitizes executable HTML while preserving supported assistant markup", async () => { |
| | 590 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| | 591 | const markup = renderToStaticMarkup( |
| | 592 | <MessagesTimeline |
| | 593 | {...buildProps()} |
| | 594 | timelineEntries={[ |
| | 595 | buildAssistantTimelineEntry( |
| | 596 | [ |
| | 597 | '<details open onclick="globalThis.__t3Xss = 1">', |
| | 598 | "<summary>Safe details</summary>", |
| | 599 | "<script>globalThis.__t3Xss = 2</script>", |
| | 600 | '<img src="x" onerror="globalThis.__t3Xss = 3">', |
| | 601 | '<a href="javascript:globalThis.__t3Xss = 4">Unsafe link</a>', |
| | 602 | "</details>", |
| | 603 | ].join(""), |
| | 604 | ), |
| | 605 | ]} |
| | 606 | />, |
| | 607 | ); |
| | 608 | |
| | 609 | expect(markup).toContain('data-markdown-details=""'); |
| | 610 | expect(markup).toContain("Safe details"); |
| | 611 | expect(markup).not.toMatch(/<script(?:\s|>)/i); |
| | 612 | expect(markup).not.toContain("onclick="); |
| | 613 | expect(markup).not.toContain("onerror="); |
| | 614 | expect(markup).not.toContain("javascript:"); |
| | 615 | expect(markup).not.toContain("globalThis.__t3Xss"); |
| | 616 | }); |
| | 617 | |
| | 618 | it("renders inline terminal labels with the composer chip UI", async () => { |
| | 619 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 474 | const markup = renderToStaticMarkup( | 620 | const markup = renderToStaticMarkup( |
| 475 | <MessagesTimeline | 621 | <MessagesTimeline |
| 476 | {...buildProps()} | 622 | {...buildProps()} |