Skip to content

feat(provider/terminal): add richlink content type support#42

Open
Zane Wang (zelinewang) wants to merge 2 commits into
photon-hq:mainfrom
zelinewang:feat/terminal-richlink-content
Open

feat(provider/terminal): add richlink content type support#42
Zane Wang (zelinewang) wants to merge 2 commits into
photon-hq:mainfrom
zelinewang:feat/terminal-richlink-content

Conversation

@zelinewang
Copy link
Copy Markdown

@zelinewang Zane Wang (zelinewang) commented May 1, 2026

Summary

Adds richlink content type support to the terminal provider — encode in spectrumToProtocol, decode in protocolToSpectrum. Closes the UnsupportedError gap that surfaced after #29 introduced richlink as a first-class content type.

Background

#29 added richlink to spectrum-ts as a content type with lazy async accessors for title / summary / cover. None of the existing providers (iMessage, WhatsApp Business, terminal) handle it yet — they all throw UnsupportedError. This PR closes the gap for terminal.

Usage

import { Spectrum, richlink } from "spectrum-ts";
import { terminal } from "spectrum-ts/providers/terminal";

const app = await Spectrum({ providers: [terminal.config()] });
for await (const [space] of app.messages) {
  await space.send(richlink("https://example.com/article"));
}

Changes

File Change
packages/spectrum-ts/src/providers/terminal/protocol.ts Adds richlink variant to ProtocolContent ({ type: "richlink"; url; title?; summary?; cover?: { mimeType?; bytes? } }). cover.bytes? mirrors the attachment / voice bytes? precedent.
packages/spectrum-ts/src/providers/terminal/index.ts Adds encode + decode branches mirroring the existing attachment / voice / contact lazy-accessor wrapping pattern. Imports richlinkSchema + RichlinkCover + bufferToStream. Updates the UnsupportedError doc-comment example to effect (since richlink is now supported here).

Design notes

Encode (spectrumToProtocol) — Resolves the lazy accessors eagerly (Promise.all([title(), summary(), cover()])), inlines cover bytes as base64. An empty cover buffer (e.g. buildCover's catch-all returns Buffer.alloc(0) after a failed image fetch) is dropped to save wire space.

Decode (protocolToSpectrum) — Wraps the static wire values back into the lazy-accessor shape richlinkSchema requires. The accessors are trivial closures — no network call on the receiver, mirroring how attachment decode keeps read() lazy.

Wire-boundary normalization — three runtime safety guards at the decode boundary:

  1. title / summary: normalize ""undefined. The protocol type permits "" (JSON has no min-length) but richlinkSchema enforces z.string().min(1).optional() and Zod v4 validates the accessor return value at call time. Without normalization, a peer sending an empty string would defer a ZodError into the agent's await decoded.title().
  2. cover.mimeType: same ""undefined (same Zod constraint on richlinkCoverSchema).
  3. cover.bytes: optional in the protocol type. Decoder guards coverWire?.bytes truthy before constructing the cover; otherwise treats as no-cover (avoids Buffer.from(undefined, "base64") crashing on a malformed cover: { mimeType: "..." } payload).

Verification

Local environment: macOS arm64, Bun 1.3.11.

Tier Result
bun run check (Ultracite / Biome) ✓ 75 files, 0 issues
bun run build (tsup ESM + DTS) ✓ ~3s
Contract test (8 scenarios / 19 assertions: full round-trip + empty metadata + empty cover bytes elision + 256-byte binary identity + 4 wire-boundary regression scenarios) ✓ All pass
TCP-level E2E against pinned tuichat v0.1.4 (real spawn + real LSP-framed JSON-RPC + real send with richlink content) SendResult{id, timestamp}, no -32099, exit code 0

The wire-boundary normalization fixes were caught by a multi-engine review pass before push (Codex + Claude reviewers + cross-scorer): three independent reviewers flagged the empty-string Zod deferral pattern; one additionally flagged the missing cover.bytes guard with a Buffer.from(undefined) reproduction. All three fixes were verified by adding regression scenarios to the contract test.

Verification scripts kept locally as artifacts; not part of this PR since the project doesn't have test infrastructure yet — happy to add a bun:test setup as a follow-up if welcome.

Limitations / follow-ups

  • tuichat visual renderingtuichat v0.1.4 (the pinned default binary) accepts the wire shape (verified empirically — no -32099) but doesn't yet render richlink content visually. internal/protocol/types.go Content struct has no Url / Title / Summary / Cover fields, so unknown wire fields are silently dropped on Go's Unmarshal. Filed as feat(rendering): tuichat does not render richlink content type tuichat#1 for tracking. The wire-level integration in this PR is forward-compatible — no spectrum-ts-side change needed when tuichat ships rendering.
  • as SpectrumContent cast — My new decode branch ends with richlinkSchema.parse(...) as SpectrumContent, consistent with the five existing branches in protocolToSpectrum. The project's .claude/CLAUDE.md recommends "leverage TypeScript's type narrowing instead of type assertions" — happy to follow up with a separate refactor PR converting all five branches to schema-inferred types, if that direction is welcome.

Test plan

  • bun run check
  • bun run build
  • Contract test (round-trip + edge cases + 4 wire-boundary regression)
  • TCP-level E2E against real tuichat v0.1.4
  • Maintainer review

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

Summary by CodeRabbit

  • New Features

    • Richlink content support added to the terminal provider: send and receive rich links with optional title, summary, and cover images. Cover image data is serialized for reliable transmission.
  • Bug Fixes / Resilience

    • Decoding is more robust: empty or missing fields are normalized and invalid richlink payloads gracefully fall back to the generic payload handling.

Encode + decode for `richlink` in the terminal provider — mirrors the
existing `attachment`/`voice`/`contact` lazy-accessor wrapping pattern.
Decoder normalizes empty strings to undefined and guards missing cover
bytes so peer-side malformed shapes don't defer ZodErrors or crash
Buffer.from. Closes the UnsupportedError gap that surfaced after photon-hq#29
introduced richlink as a first-class content type.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a8809621-332e-4786-bfde-14d42b7f747d

📥 Commits

Reviewing files that changed from the base of the PR and between 392551f and 8af9400.

📒 Files selected for processing (1)
  • packages/spectrum-ts/src/providers/terminal/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/spectrum-ts/src/providers/terminal/index.ts

📝 Walkthrough

Walkthrough

Adds bidirectional richlink support to the terminal provider: outbound converts Spectrum richlink lazy accessors to plain protocol fields (base64-encodes cover bytes, omits zero-length covers); inbound reconstructs lazy accessors from protocol wire values (empty strings → undefined), rebuilds cover read/stream from base64, and validates via richlinkSchema.

Changes

Cohort / File(s) Summary
Protocol Definition
packages/spectrum-ts/src/providers/terminal/protocol.ts
Adds a new ProtocolContent union member { type: "richlink"; url: string; title?: string; summary?: string; cover?: { mimeType?: string; bytes?: string } }.
Terminal Provider Conversion
packages/spectrum-ts/src/providers/terminal/index.ts
Implements outbound spectrumToProtocol conversion that resolves richlink lazy accessors, base64-encodes cover bytes, and omits zero-length covers; implements inbound protocolToSpectrum that converts empty-wire values to undefined, reconstructs lazy read()/stream() cover accessors from base64 bytes (or treats missing bytes as no cover), and validates via richlinkSchema with fallback to custom on parse failure.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • underthestars-zhy

Poem

I’m a rabbit with bytes in my paw,
I stitch links of wonder without a flaw,
Base64 blankets for covers so bright,
Lazy carrots of title and summary take flight,
Hopping between protocol and spectrum delight! 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat(provider/terminal): add richlink content type support' accurately summarizes the main change—adding richlink content type support to the terminal provider. It is concise, specific, and clearly conveys the primary objective.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the release Just as it is label May 1, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/spectrum-ts/src/providers/terminal/index.ts`:
- Around line 608-614: richlinkSchema.parse can throw on malformed input and
halt the event stream; replace the direct parse with a non-throwing path (either
richlinkSchema.safeParse(...) or wrap richlinkSchema.parse(...) in a try/catch)
inside the function returning SpectrumContent and, on validation failure, return
a soft-fail fallback object with type: "custom" (preserving the original
url/title/summary/cover accessors or raw payload as appropriate) so bad richlink
payloads don't break processing; update the return site that currently
references richlinkSchema.parse(...) and ensure the fallback still satisfies
SpectrumContent shape.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d1f876d6-2eaa-4ee7-ba0e-07cf3a2eaf58

📥 Commits

Reviewing files that changed from the base of the PR and between 0080fc8 and 392551f.

📒 Files selected for processing (2)
  • packages/spectrum-ts/src/providers/terminal/index.ts
  • packages/spectrum-ts/src/providers/terminal/protocol.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use explicit types for function parameters and return values when they enhance clarity in TypeScript
Prefer unknown over any when the type is genuinely unknown in TypeScript
Use const assertions (as const) for immutable values and literal types in TypeScript
Leverage TypeScript's type narrowing instead of type assertions

Files:

  • packages/spectrum-ts/src/providers/terminal/protocol.ts
  • packages/spectrum-ts/src/providers/terminal/index.ts
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,jsx,tsx}: Use meaningful variable names instead of magic numbers - extract constants with descriptive names
Use arrow functions for callbacks and short functions in JavaScript/TypeScript
Prefer for...of loops over .forEach() and indexed for loops in JavaScript/TypeScript
Use optional chaining (?.) and nullish coalescing (??) for safer property access in JavaScript/TypeScript
Prefer template literals over string concatenation in JavaScript/TypeScript
Use destructuring for object and array assignments in JavaScript/TypeScript
Use const by default, let only when reassignment is needed, never var in JavaScript/TypeScript
Always await promises in async functions - don't forget to use the return value in JavaScript/TypeScript
Use async/await syntax instead of promise chains for better readability in JavaScript/TypeScript
Handle errors appropriately in async code with try-catch blocks in JavaScript/TypeScript
Don't use async functions as Promise executors in JavaScript/TypeScript
Remove console.log, debugger, and alert statements from production code in JavaScript/TypeScript
Throw Error objects with descriptive messages, not strings or other values in JavaScript/TypeScript
Use try-catch blocks meaningfully - don't catch errors just to rethrow them in JavaScript/TypeScript
Prefer early returns over nested conditionals for error cases in JavaScript/TypeScript
Keep functions focused and under reasonable cognitive complexity limits
Extract complex conditions into well-named boolean variables in JavaScript/TypeScript
Use early returns to reduce nesting in JavaScript/TypeScript
Prefer simple conditionals over nested ternary operators in JavaScript/TypeScript
Group related code together and separate concerns in JavaScript/TypeScript
Don't use eval() or assign directly to document.cookie in JavaScript/TypeScript
Validate and sanitize user input in JavaScript/TypeScript
Avoid spread syntax in accumulators within loops in JavaScript/Ty...

Files:

  • packages/spectrum-ts/src/providers/terminal/protocol.ts
  • packages/spectrum-ts/src/providers/terminal/index.ts
🔇 Additional comments (2)
packages/spectrum-ts/src/providers/terminal/protocol.ts (1)

38-44: Richlink protocol variant is well-scoped and consistent.

The new discriminated union member cleanly matches the provider conversion contract.

packages/spectrum-ts/src/providers/terminal/index.ts (1)

453-479: Richlink outbound serialization looks solid.

Eager accessor resolution and empty-cover elision are implemented correctly for the wire format.

Comment thread packages/spectrum-ts/src/providers/terminal/index.ts Outdated
Wrap `richlinkSchema.parse` in try/catch and let the catch fall through
to the existing unknown-shape fallback (`{ type: "custom", raw: p }`)
at the function tail. A non-conforming peer sending an invalid `url`
(or omitting it entirely) would otherwise raise a `ZodError` from
`protocolToSpectrum`, halting the inbound message stream.

Mirrors the contact-decode try/catch at lines 526-532, which already
swallows malformed-vCard zod errors for the same reason.

Addresses CodeRabbit review feedback on photon-hq#42.
@underthestars-zhy
Copy link
Copy Markdown
Member

Ryan Zhu (underthestars-zhy) commented May 1, 2026

Zane Wang (@zelinewang) does this require any changes in tuichat? if doesn't, how can the tuichat render the richlink

@zelinewang
Copy link
Copy Markdown
Author

Zane Wang (zelinewang) commented May 1, 2026

Ryan Zhu (@underthestars-zhy) From what I could test against tuichat v0.1.4, the wire round-trips fine. For rendering, yes i opened tuichat#1 for a separate followup on rendering.

This PR only adds the terminal adapter side, tested with tuichat v0.1.4, the richlink send returns SendResult instead of -32099, but tuichat does not render it yet; For tuichat#1, we need to make Content model handle url / title / summary / cover

@underthestars-zhy
Copy link
Copy Markdown
Member

Zane Wang (@zelinewang) do u mind also submitting a pr to tuichat so it does support the rendering

@zelinewang
Copy link
Copy Markdown
Author

Opened draft PR photon-hq/tuichat#2 for the rendering side. Kept it text-only first and left Pattern A vs Raw passthrough / cover rendering as explicit design questions in the PR body.

@underthestars-zhy
Copy link
Copy Markdown
Member

can we also render the image or have a little button that we hover and display and image? like what we have with the current attachmemt

@zelinewang
Copy link
Copy Markdown
Author

Zane Wang (zelinewang) commented May 2, 2026

Yes. I updated photon-hq/tuichat#2 in commit 6c876b4 to model cover and reuse the existing attachment preview path: richlinks with cover.bytes + a supported image MIME now show (click to preview) and open the same floating preview panel as attachments. No spectrum-ts wire change needed.

Verified with go build ./..., go vet ./..., go test ./... -race, plus TCP E2E against spectrum-ts#42: richlink with cover still returns SendResult and no -32099.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release Just as it is

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants