diff --git a/.changeset/api-write-surface-ids.md b/.changeset/api-write-surface-ids.md new file mode 100644 index 0000000..5819d93 --- /dev/null +++ b/.changeset/api-write-surface-ids.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Return per-surface ids, kinds, and indexes in write responses without echoing surface payload bodies, and remove the redundant top-level `kinds` array. Read kinds from `surfaces.map((surface) => surface.kind)` instead. diff --git a/.changeset/session-posts-surfaces.md b/.changeset/session-posts-surfaces.md new file mode 100644 index 0000000..eb9d0f6 --- /dev/null +++ b/.changeset/session-posts-surfaces.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +List session posts with canonical `surfaces` entries that include surface ids and omit elided html bodies. diff --git a/.changeset/surface-read-indexes.md b/.changeset/surface-read-indexes.md new file mode 100644 index 0000000..457be5a --- /dev/null +++ b/.changeset/surface-read-indexes.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Expose derived 0-based surface indexes on post detail, session post list, and post history read responses. diff --git a/bin/sideshow.js b/bin/sideshow.js index 99bcdec..c11ac44 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -129,7 +129,7 @@ usage: --surface is a deprecated alias) --author defaults to agent name sideshow list [--session |--all] list posts - sideshow show show a single post (surfaces, ids, version, history) + sideshow show show a single post (surfaces, indexes, ids, version, history) sideshow sessions list sessions sideshow demo seed two example sessions to explore the viewer sideshow guide print the design contract for posts diff --git a/mcp/server.ts b/mcp/server.ts index eff0930..a492e1d 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { feedbackView, mcpPostListRowView } from "../server/apiViews.ts"; import { MCP_INSTRUCTIONS, MCP_SERVER_INFO, @@ -95,7 +96,8 @@ server.registerTool( { description: MCP_TOOL_DESCRIPTIONS.listPostsStdio, inputSchema: {} }, async () => { if (!sessionId) return text([]); - return text(JSON.parse(await api(`/api/sessions/${sessionId}/posts`))); + const rows = JSON.parse(await api(`/api/sessions/${sessionId}/posts`)); + return text(rows.map(mcpPostListRowView)); }, ); @@ -196,12 +198,7 @@ server.registerTool( return text({ comments: [], note: "no user feedback yet — continue, or wait again later" }); } return text({ - comments: result.comments.map((c: any) => ({ - surfaceId: c.postId, - surfaceTitle: c.postTitle, - text: c.text, - at: c.createdAt, - })), + comments: result.comments.map(feedbackView), }); }, ); @@ -228,7 +225,8 @@ server.registerTool( { description: MCP_TOOL_DESCRIPTIONS.listSurfacesStdio, inputSchema: {} }, async () => { if (!sessionId) return text([]); - return text(JSON.parse(await api(`/api/sessions/${sessionId}/surfaces`))); + const rows = JSON.parse(await api(`/api/sessions/${sessionId}/surfaces`)); + return text(rows.map(mcpPostListRowView)); }, ); diff --git a/server/apiViews.ts b/server/apiViews.ts new file mode 100644 index 0000000..5a9cc23 --- /dev/null +++ b/server/apiViews.ts @@ -0,0 +1,218 @@ +import type { Comment, CommentAnchor, Post, Session, Surface } from "./types.ts"; + +export interface Feedback { + postId: string | null; + postTitle: string | null; + surfaceId: string | null; + surfaceTitle: string | null; + text: string; + at: string; + anchor?: CommentAnchor; +} + +export const surfaceRef = (surface: Pick, index: number) => ({ + id: surface.id, + kind: surface.kind, + index, +}); + +export const fullSurfaceView = (surface: Surface, index: number) => ({ ...surface, index }); + +// Session REST lists keep non-html structured payloads for the viewer list, but +// omit arbitrary html bodies. Legacy `parts` aliases the same array at the row. +export const sessionListSurfaceView = (surface: Surface, index: number) => + surface.kind === "html" ? surfaceRef(surface, index) : fullSurfaceView(surface, index); + +export const postWriteView = (post: Post) => ({ + id: post.id, + sessionId: post.sessionId, + title: post.title, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + version: post.version, + surfaces: post.surfaces.map(surfaceRef), +}); + +export const postDetailView = (post: Post) => ({ + ...post, + surfaces: post.surfaces.map(fullSurfaceView), + history: post.history.map((version) => ({ + ...version, + surfaces: version.surfaces.map(fullSurfaceView), + })), +}); + +export const sessionPostListRowView = (post: Post) => { + const surfaces = post.surfaces.map(sessionListSurfaceView); + return { + id: post.id, + sessionId: post.sessionId, + title: post.title, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + version: post.version, + surfaces, + parts: surfaces, + }; +}; + +export const mcpPostListRowView = ( + post: Pick & { + surfaces: Pick[]; + }, +) => ({ + id: post.id, + sessionId: post.sessionId, + title: post.title, + version: post.version, + updatedAt: post.updatedAt, + surfaces: post.surfaces.map(surfaceRef), +}); + +const PART_TEXT_CAP = 8_000; +const TRACE_STEP_PREVIEW_LIMIT = 25; +type CappedSurface = Surface & { truncated?: true }; + +function capText(text: string): { value: string; truncated: boolean } { + return text.length > PART_TEXT_CAP + ? { value: text.slice(0, PART_TEXT_CAP), truncated: true } + : { value: text, truncated: false }; +} + +function capSurface(surface: Surface): CappedSurface { + switch (surface.kind) { + case "html": { + const { value, truncated } = capText(surface.html); + return truncated ? { ...surface, html: value, truncated: true } : surface; + } + case "markdown": { + const { value, truncated } = capText(surface.markdown); + return truncated ? { ...surface, markdown: value, truncated: true } : surface; + } + case "mermaid": { + const { value, truncated } = capText(surface.mermaid); + return truncated ? { ...surface, mermaid: value, truncated: true } : surface; + } + case "code": { + const { value, truncated } = capText(surface.code); + return truncated ? { ...surface, code: value, truncated: true } : surface; + } + case "terminal": { + const { value, truncated } = capText(surface.text); + return truncated ? { ...surface, text: value, truncated: true } : surface; + } + case "diff": { + let truncated = false; + const next: CappedSurface = { ...surface }; + if (surface.patch !== undefined) { + const capped = capText(surface.patch); + next.patch = capped.value; + truncated ||= capped.truncated; + } + if (surface.files !== undefined) { + next.files = surface.files.map((file) => { + const before = capText(file.before); + const after = capText(file.after); + const filename = capText(file.filename); + const language = file.language ? capText(file.language) : undefined; + truncated ||= before.truncated || after.truncated || filename.truncated; + if (language) truncated ||= language.truncated; + return { + ...file, + filename: filename.value, + before: before.value, + after: after.value, + ...(language && { language: language.value }), + }; + }); + } + return truncated ? { ...next, truncated: true } : surface; + } + case "image": { + const alt = surface.alt ? capText(surface.alt) : undefined; + const caption = surface.caption ? capText(surface.caption) : undefined; + const truncated = !!alt?.truncated || !!caption?.truncated; + return truncated + ? { + ...surface, + ...(alt && { alt: alt.value }), + ...(caption && { caption: caption.value }), + truncated: true, + } + : surface; + } + case "trace": { + let truncated = false; + const title = surface.title ? capText(surface.title) : undefined; + if (title?.truncated) truncated = true; + const steps = surface.steps?.slice(0, TRACE_STEP_PREVIEW_LIMIT).map((step) => { + const label = capText(step.label); + const kind = step.kind ? capText(step.kind) : undefined; + const detail = step.detail ? capText(step.detail) : undefined; + const ts = step.ts ? capText(step.ts) : undefined; + truncated ||= + label.truncated || !!kind?.truncated || !!detail?.truncated || !!ts?.truncated; + return { + label: label.value, + ...(kind && { kind: kind.value }), + ...(detail && { detail: detail.value }), + ...(ts && { ts: ts.value }), + }; + }); + if ((surface.steps?.length ?? 0) > TRACE_STEP_PREVIEW_LIMIT) truncated = true; + return truncated + ? { + ...surface, + ...(title && { title: title.value }), + ...(steps && { steps }), + truncated: true, + } + : surface; + } + case "json": { + const serialized = JSON.stringify(surface.data); + const { value, truncated } = capText(serialized); + return truncated ? { ...surface, data: value, truncated: true } : surface; + } + default: + return surface; + } +} + +export const recentSurfacePreviewView = (surface: Surface, index: number) => ({ + ...capSurface(surface), + index, +}); + +export const recentPostRowView = (post: Post, session: Session | null | undefined) => { + const surfaces = post.surfaces.map(recentSurfacePreviewView); + return { + id: post.id, + sessionId: post.sessionId, + sessionTitle: session?.title ?? null, + agent: session?.agent ?? null, + title: post.title, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + version: post.version, + surfaces, + parts: surfaces, + partKinds: post.surfaces.map((surface) => surface.kind), + }; +}; + +export const feedbackView = (comment: Comment): Feedback => ({ + postId: comment.postId, + postTitle: comment.postTitle, + surfaceId: comment.postId, + surfaceTitle: comment.postTitle, + text: comment.text, + at: comment.createdAt, + ...(comment.anchor && { anchor: comment.anchor }), +}); + +export const sessionRowView = (session: Session, postCount: number) => ({ + ...session, + postCount, + surfaceCount: postCount, +}); diff --git a/server/app.ts b/server/app.ts index c20dd69..6e15931 100644 --- a/server/app.ts +++ b/server/app.ts @@ -3,6 +3,15 @@ import { bodyLimit } from "hono/body-limit"; import { getCookie, setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import { decodeBase64 } from "./base64.ts"; +import { + feedbackView, + postDetailView, + postWriteView, + recentPostRowView, + sessionPostListRowView, + sessionRowView, + type Feedback, +} from "./apiViews.ts"; import { EventBus, type FeedEvent } from "./events.ts"; import { kitSummaries } from "./kits.ts"; import { registerMcp } from "./mcpHttp.ts"; @@ -35,6 +44,7 @@ import { import { validateSurfaces } from "./postSurfaces.ts"; export type { FeedEvent } from "./events.ts"; +export type { Feedback } from "./apiViews.ts"; const MAX_SURFACE_BYTES = 2 * 1024 * 1024; const MAX_WAIT_SECONDS = 300; @@ -213,134 +223,6 @@ async function fetchLatestFromRegistry(): Promise { const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000; -// html surfaces carry arbitrary markup the viewer renders via a sandboxed iframe, -// so the card list never needs their bodies — strip them to a kind marker. -// diff surfaces are structured data the viewer renders inline, so keep them whole. -const stripHtmlBodies = (surfaces: Surface[]): Surface[] => - surfaces.map((surface) => (surface.kind === "html" ? { kind: "html", html: "" } : surface)); - -const postMeta = (post: Post) => ({ - id: post.id, - sessionId: post.sessionId, - title: post.title, - createdAt: post.createdAt, - updatedAt: post.updatedAt, - version: post.version, - // Legacy wire name: session list responses still expose `parts` for older clients. - parts: stripHtmlBodies(post.surfaces), -}); - -// Cap inline preview fields so /api/surfaces/recent stays cheap while still -// carrying a real clipped preview. Unlike stripHtmlBodies (which empties html -// for the card list), this TRUNCATES large inline payloads so a feed card can render an -// honest preview. Assets stay by-reference (assetId). When a field is clipped we -// set `truncated:true` on that part so a client can offer a "view full post" -// affordance honestly. -const PART_TEXT_CAP = 8_000; // chars; ~a screenful, enough for a clipped preview -const TRACE_STEP_PREVIEW_LIMIT = 25; -type CappedSurface = Surface & { truncated?: true }; -function capText(text: string): { value: string; truncated: boolean } { - return text.length > PART_TEXT_CAP - ? { value: text.slice(0, PART_TEXT_CAP), truncated: true } - : { value: text, truncated: false }; -} -function capParts(parts: Surface[]): CappedSurface[] { - return parts.map((p): CappedSurface => { - switch (p.kind) { - case "html": { - const { value, truncated } = capText(p.html); - return truncated ? { ...p, html: value, truncated: true } : p; - } - case "markdown": { - const { value, truncated } = capText(p.markdown); - return truncated ? { ...p, markdown: value, truncated: true } : p; - } - case "mermaid": { - const { value, truncated } = capText(p.mermaid); - return truncated ? { ...p, mermaid: value, truncated: true } : p; - } - case "code": { - const { value, truncated } = capText(p.code); - return truncated ? { ...p, code: value, truncated: true } : p; - } - case "terminal": { - const { value, truncated } = capText(p.text); - return truncated ? { ...p, text: value, truncated: true } : p; - } - case "diff": { - let truncated = false; - const next: CappedSurface = { ...p }; - if (p.patch !== undefined) { - const capped = capText(p.patch); - next.patch = capped.value; - truncated ||= capped.truncated; - } - if (p.files !== undefined) { - next.files = p.files.map((file) => { - const before = capText(file.before); - const after = capText(file.after); - const filename = capText(file.filename); - const language = file.language ? capText(file.language) : undefined; - truncated ||= before.truncated || after.truncated || filename.truncated; - if (language) truncated ||= language.truncated; - return { - ...file, - filename: filename.value, - before: before.value, - after: after.value, - ...(language && { language: language.value }), - }; - }); - } - return truncated ? { ...next, truncated: true } : p; - } - case "image": { - const alt = p.alt ? capText(p.alt) : undefined; - const caption = p.caption ? capText(p.caption) : undefined; - const truncated = !!alt?.truncated || !!caption?.truncated; - return truncated - ? { - ...p, - ...(alt && { alt: alt.value }), - ...(caption && { caption: caption.value }), - truncated: true, - } - : p; - } - case "trace": { - let truncated = false; - const title = p.title ? capText(p.title) : undefined; - if (title?.truncated) truncated = true; - const steps = p.steps?.slice(0, TRACE_STEP_PREVIEW_LIMIT).map((step) => { - const label = capText(step.label); - const kind = step.kind ? capText(step.kind) : undefined; - const detail = step.detail ? capText(step.detail) : undefined; - const ts = step.ts ? capText(step.ts) : undefined; - truncated ||= - label.truncated || !!kind?.truncated || !!detail?.truncated || !!ts?.truncated; - return { - label: label.value, - ...(kind && { kind: kind.value }), - ...(detail && { detail: detail.value }), - ...(ts && { ts: ts.value }), - }; - }); - if ((p.steps?.length ?? 0) > TRACE_STEP_PREVIEW_LIMIT) truncated = true; - return truncated - ? { ...p, ...(title && { title: title.value }), ...(steps && { steps }), truncated: true } - : p; - } - case "json": { - const serialized = JSON.stringify(p.data); - const { value, truncated } = capText(serialized); - return truncated ? { ...p, data: value, truncated: true } : p; - } - default: - return p; - } - }); -} - function parseRecentLimit(raw: string | undefined): number { const parsed = Number(raw ?? "20"); const limit = Number.isFinite(parsed) && parsed !== 0 ? Math.trunc(parsed) : 20; @@ -358,6 +240,7 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { // /api/sessions (NOT public on a session-scoped workspace), not like the // per-surface /api/surfaces/:id reads below. if (path === "/api/surfaces/recent") return false; + if (path === "/api/posts/recent") return false; if (path.startsWith("/api/surfaces/")) return true; if (path.startsWith("/api/posts/")) return true; if (path.startsWith("/api/snippets/")) return true; @@ -369,19 +252,6 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { return false; } -// Response to an agent's own write: it already holds the surfaces it just sent, -// so echo only the identifiers (a diff patch can be large — never send it -// back). Reads (the post list and GET /api/surfaces/:id) carry the surfaces. -const writeResult = (s: Post) => ({ - id: s.id, - sessionId: s.sessionId, - title: s.title, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - version: s.version, - kinds: s.surfaces.map((p) => p.kind), -}); - export interface CommentWait { sessionId?: string; surfaceId?: string; @@ -390,23 +260,6 @@ export interface CommentWait { waitSeconds: number; } -export interface Feedback { - surfaceId: string | null; - surfaceTitle: string | null; - text: string; - at: string; - anchor?: CommentAnchor; -} - -// Lean comment shape attached to agent-facing responses. -const feedbackView = (c: Comment): Feedback => ({ - surfaceId: c.postId, - surfaceTitle: c.postTitle, - text: c.text, - at: c.createdAt, - ...(c.anchor && { anchor: c.anchor }), -}); - export function createApp({ store, viewerHtml, @@ -1146,7 +999,7 @@ export function createApp({ const [sessions, surfaces] = await Promise.all([store.listSessions(), store.listPosts()]); const counts = new Map(); for (const s of surfaces) counts.set(s.sessionId, (counts.get(s.sessionId) ?? 0) + 1); - return c.json(sessions.map((s) => ({ ...s, surfaceCount: counts.get(s.id) ?? 0 }))); + return c.json(sessions.map((s) => sessionRowView(s, counts.get(s.id) ?? 0))); }); // --- recent surfaces (post-grained feed source) --- @@ -1155,14 +1008,15 @@ export function createApp({ // row per post (post-grained), distinct from the session-grained GET // /api/sessions. This is the source a cross-session "latest posts" feed needs // (Org Home, a per-workspace Home): each item carries its session id/title + - // agent for the feed card, the post's part kinds, and capped part previews. + // agent for the feed card, canonical surfaces, legacy partKinds, and capped + // previews. // - // Previews are bounded by capParts (large inline text clipped to PART_TEXT_CAP - // with truncated:true); images travel as plain assetId refs (served at /a/:id), + // Previews are bounded by recentPostRowView (large inline text clipped with + // truncated:true); images travel as plain assetId refs (served at /a/:id), // so the response stays cheap. Same auth as /api/sessions — see // isPublicReadAllowed, which intentionally does NOT expose this path on a // session-scoped publicRead workspace. - app.get("/api/surfaces/recent", async (c) => { + const listRecentPosts = async (c: any) => { const limit = parseRecentLimit(c.req.query("limit")); const posts = await store.listRecentPosts(limit); // Resolve each post's session once (agent + session title for the feed card). @@ -1171,24 +1025,10 @@ export function createApp({ if (!sessions.has(p.sessionId)) sessions.set(p.sessionId, await store.getSession(p.sessionId)); } - return c.json( - posts.map((p) => { - const s = sessions.get(p.sessionId); - return { - id: p.id, - sessionId: p.sessionId, - sessionTitle: s?.title ?? null, - agent: s?.agent ?? null, - title: p.title, - createdAt: p.createdAt, - updatedAt: p.updatedAt, - version: p.version, - partKinds: p.surfaces.map((x) => x.kind), - parts: capParts(p.surfaces), - }; - }), - ); - }); + return c.json(posts.map((p) => recentPostRowView(p, sessions.get(p.sessionId)))); + }; + app.get("/api/surfaces/recent", listRecentPosts); + app.get("/api/posts/recent", listRecentPosts); app.post("/api/sessions", async (c) => { const body = await c.req.json().catch(() => ({})); @@ -1223,7 +1063,7 @@ export function createApp({ const session = await store.getSession(c.req.param("id")); if (!session) return c.json({ error: "session not found" }, 404); const posts = await store.listPosts(session.id); - return c.json(posts.map(postMeta)); + return c.json(posts.map(sessionPostListRowView)); }; app.get("/api/sessions/:id/surfaces", listSessionPosts); // legacy alias app.get("/api/sessions/:id/posts", listSessionPosts); @@ -1271,7 +1111,7 @@ export function createApp({ const getPost = async (c: any) => { const post = await store.getPost(c.req.param("id")); if (!post) return c.json({ error: "post not found" }, 404); - return c.json(post); + return c.json(postDetailView(post)); }; app.get("/api/surfaces/:id", getPost); // legacy alias app.get("/api/posts/:id", getPost); @@ -1318,7 +1158,7 @@ export function createApp({ if ("error" in result) return c.json({ error: result.error }, result.status); return c.json( { - ...writeResult(result.post), + ...postWriteView(result.post), ...(result.userFeedback && { userFeedback: result.userFeedback }), }, 201, @@ -1352,7 +1192,7 @@ export function createApp({ }); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ - ...writeResult(result.post), + ...postWriteView(result.post), ...(result.userFeedback && { userFeedback: result.userFeedback }), }); }; @@ -1417,7 +1257,7 @@ export function createApp({ }); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ - ...writeResult(result.post), + ...postWriteView(result.post), ...(result.userFeedback && { userFeedback: result.userFeedback }), }); }); @@ -1439,7 +1279,7 @@ export function createApp({ }); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ - ...writeResult(result.post), + ...postWriteView(result.post), ...(result.userFeedback && { userFeedback: result.userFeedback }), }); }); @@ -1465,7 +1305,7 @@ export function createApp({ }); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ - ...writeResult(result.post), + ...postWriteView(result.post), ...(result.userFeedback && { userFeedback: result.userFeedback }), }); }); @@ -1476,7 +1316,7 @@ export function createApp({ const result = await removePostSurface(c.req.param("id"), c.req.param("target")); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ - ...writeResult(result.post), + ...postWriteView(result.post), ...(result.userFeedback && { userFeedback: result.userFeedback }), }); }); @@ -1490,7 +1330,7 @@ export function createApp({ const result = await reorderPostSurfaces(c.req.param("id"), body.order); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ - ...writeResult(result.post), + ...postWriteView(result.post), ...(result.userFeedback && { userFeedback: result.userFeedback }), }); }); diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index 0f7f2b7..dca777a 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -1,5 +1,6 @@ import type { Hono } from "hono"; import type { CommentWait, Feedback } from "./app.ts"; +import { feedbackView, mcpPostListRowView, postDetailView, postWriteView } from "./apiViews.ts"; import { decodeBase64 } from "./base64.ts"; import { type Asset, @@ -75,9 +76,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { ) => JSON.stringify( { - id: result.post.id, - sessionId: result.post.sessionId, - version: result.post.version, + ...postWriteView(result.post), url: `${origin}/${seg}/${result.post.id}`, ...(result.userFeedback && { userFeedback: result.userFeedback }), }, @@ -142,13 +141,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { } return JSON.stringify( { - comments: result.comments.map((c) => ({ - surfaceId: c.postId, - surfaceTitle: c.postTitle, - text: c.text, - at: c.createdAt, - ...(c.anchor && { anchor: c.anchor }), - })), + comments: result.comments.map(feedbackView), lastSeq: result.lastSeq, }, null, @@ -179,23 +172,12 @@ export function registerMcp(app: Hono, deps: McpDeps) { const posts = await deps.store.listPosts( typeof args.session === "string" ? args.session : undefined, ); - return JSON.stringify( - posts.map((s) => ({ - id: s.id, - sessionId: s.sessionId, - title: s.title, - kinds: s.surfaces.map((p) => p.kind), - version: s.version, - updatedAt: s.updatedAt, - })), - null, - 2, - ); + return JSON.stringify(posts.map(mcpPostListRowView), null, 2); } case "get_post": { const post = await deps.store.getPost(String(args.id ?? "")); if (!post) throw new Error("post not found"); - return JSON.stringify(post, null, 2); + return JSON.stringify(postDetailView(post), null, 2); } case "upload_asset": { if (typeof args.data !== "string" || args.data.length === 0) { diff --git a/server/mcpSpec.ts b/server/mcpSpec.ts index fe449c4..cd756e0 100644 --- a/server/mcpSpec.ts +++ b/server/mcpSpec.ts @@ -156,15 +156,17 @@ const MCP_SURFACES_JSON_SCHEMA = { export const MCP_TOOL_DESCRIPTIONS = { publishPostHttp: - "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, and sessionId — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, sessionId, and the new surface ids (use them to target a surface for later edits without a get_post round-trip) — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", publishPostStdio: - "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, and the new surface ids (use them to target a surface for later edits without a get_post round-trip). On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", updatePost: - "Revise a post in place (same card, new version). Prefer this over publishing a near-duplicate. Pass the full replacement surfaces array. If the result includes userFeedback, read it.", - listPostsHttp: "List posts — pass a session id to scope, or omit for all sessions.", - listPostsStdio: "List posts in this conversation's session.", + "Revise a post in place (same card, new version). Prefer this over publishing a near-duplicate. Pass the full replacement surfaces array. Returns the new surface ids (use them to target a surface for later edits without a get_post round-trip). If the result includes userFeedback, read it.", + listPostsHttp: + "List posts — pass a session id to scope, or omit for all sessions. Returns lean post rows with surfaces as `{id, kind, index}` metadata (no surface bodies).", + listPostsStdio: + "List posts in this conversation's session. Returns lean post rows with surfaces as `{id, kind, index}` metadata (no surface bodies).", getPost: - "Fetch a single post by id — returns the full post object including surfaces (with their ids), version, and history. Use this to recover surface ids for per-surface operations (edit_surface, remove_surface, reorder_surfaces) after a context compaction, or to inspect a post's current state before editing.", + "Fetch a single post by id — returns the full post object including surfaces (with their ids and 0-based indexes), version, and history. Use this to recover surface ids (or indexes) for per-surface operations (edit_surface, remove_surface, reorder_surfaces) after a context compaction, or to inspect a post's current state before editing.", publishSurfaceHttp: "Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, and sessionId — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", publishSurfaceStdio: @@ -179,8 +181,9 @@ export const MCP_TOOL_DESCRIPTIONS = { replyToUser: "Post a short reply under a post's comment thread. Use to acknowledge feedback or explain a revision.", listSurfacesHttp: - "Deprecated alias of list_posts — List posts; pass a session id to scope, or omit for all sessions.", - listSurfacesStdio: "Deprecated alias of list_posts — List posts in this conversation's session.", + "Deprecated alias of list_posts — List posts; pass a session id to scope, or omit for all sessions. Returns lean post rows with surfaces as `{id, kind, index}` metadata (no surface bodies).", + listSurfacesStdio: + "Deprecated alias of list_posts — List posts in this conversation's session. Returns lean post rows with surfaces as `{id, kind, index}` metadata (no surface bodies).", uploadAsset: "Upload a binary asset (image, trace file, any file) and get back its id and URL. base64-encode the bytes in `data` (MCP carries no binary). Then reference it: put {kind:'image', assetId} or {kind:'trace', assetId} in a post's surfaces, or embed the returned url in an html surface (). Pass the same session id you publish with so the asset is grouped and cleaned up with it.", uploadAssetStdio: diff --git a/test/api.test.ts b/test/api.test.ts index b1ac26d..0a97139 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -58,6 +58,7 @@ test("publish without session auto-creates one", async () => { const sessions = (await (await app.request("/api/sessions")).json()) as any; assert.equal(sessions.length, 1); assert.equal(sessions[0].agent, "pi"); + assert.equal(sessions[0].postCount, 1); assert.equal(sessions[0].surfaceCount, 1); }); @@ -170,9 +171,18 @@ test("publishes a combined html+diff surface; /s server-renders both parts opaqu ); assert.equal(res.status, 201); const surface = (await res.json()) as any; - // the write response is lean — kinds, no part bodies echoed back - assert.deepEqual(surface.kinds, ["html", "diff"]); + // the write response is lean — surface ids + kinds + indexes, no part bodies echoed back + assert.equal(surface.kinds, undefined); assert.equal(surface.parts, undefined); + assert.deepEqual( + surface.surfaces.map((p: any) => ({ id: typeof p.id, kind: p.kind, index: p.index })), + [ + { id: "string", kind: "html", index: 0 }, + { id: "string", kind: "diff", index: 1 }, + ], + ); + assert.ok(!("html" in surface.surfaces[0]), "html body is not echoed"); + assert.ok(!("patch" in surface.surfaces[1]), "diff body is not echoed"); // the full record keeps the html and the diff patch const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; @@ -180,6 +190,28 @@ test("publishes a combined html+diff surface; /s server-renders both parts opaqu assert.equal(full.surfaces[0].html, "

diagram

"); assert.equal(full.surfaces[1].patch, "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b"); + const updated = (await ( + await app.request(`/api/surfaces/${surface.id}`, { + ...json({ + parts: [ + { kind: "html", html: "

diagram

" }, + { kind: "diff", patch: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b", layout: "split" }, + ], + }), + method: "PUT", + }) + ).json()) as any; + assert.equal(updated.kinds, undefined); + assert.deepEqual( + updated.surfaces.map((p: any) => ({ id: typeof p.id, kind: p.kind, index: p.index })), + [ + { id: "string", kind: "html", index: 0 }, + { id: "string", kind: "diff", index: 1 }, + ], + ); + assert.ok(!("html" in updated.surfaces[0]), "updated html body is not echoed"); + assert.ok(!("patch" in updated.surfaces[1]), "updated diff body is not echoed"); + // /s renders the html part... const part0 = await app.request(`/s/${surface.id}?part=0`); assert.ok((await part0.text()).includes("

diagram

")); @@ -455,7 +487,10 @@ test("publishes a markdown part; /s server-renders it to sandboxed html", async ); assert.equal(res.status, 201); const surface = (await res.json()) as any; - assert.deepEqual(surface.kinds, ["markdown"]); + assert.deepEqual( + surface.surfaces.map((s: any) => s.kind), + ["markdown"], + ); const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; assert.equal(full.surfaces[0].kind, "markdown"); @@ -531,7 +566,10 @@ test("publishes a mermaid part; /s emits a self-rendering CDN doc", async () => ); assert.equal(res.status, 201); const surface = (await res.json()) as any; - assert.deepEqual(surface.kinds, ["mermaid"]); + assert.deepEqual( + surface.surfaces.map((s: any) => s.kind), + ["mermaid"], + ); const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; assert.equal(full.surfaces[0].kind, "mermaid"); @@ -561,7 +599,10 @@ test("publishes a json part; round-trips data and 404s on /s", async () => { ); assert.equal(res.status, 201); const surface = (await res.json()) as any; - assert.deepEqual(surface.kinds, ["json"]); + assert.deepEqual( + surface.surfaces.map((s: any) => s.kind), + ["json"], + ); const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; assert.equal(full.surfaces[0].kind, "json"); @@ -599,7 +640,10 @@ test("publishes a code part; round-trips code/lang/title and 404s on /s", async ); assert.equal(res.status, 201); const surface = (await res.json()) as any; - assert.deepEqual(surface.kinds, ["code"]); + assert.deepEqual( + surface.surfaces.map((s: any) => s.kind), + ["code"], + ); const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; assert.equal(full.surfaces[0].kind, "code"); @@ -1325,6 +1369,10 @@ test("mcp endpoint: initialize, tools/list, publish round trip", async () => { ).json()) as any; const fb = JSON.parse(feedback.result.content[0].text); assert.equal(fb.comments.length, 1); + assert.equal(fb.comments[0].postId, payload.id); + assert.equal(fb.comments[0].postTitle, "Via MCP"); + assert.equal(fb.comments[0].surfaceId, payload.id); + assert.equal(fb.comments[0].surfaceTitle, "Via MCP"); assert.equal(fb.comments[0].text, "nice"); assert.ok(fb.lastSeq > 0); }); @@ -1537,6 +1585,9 @@ test("agent writes piggyback unseen user comments, delivered once", async () => updated.userFeedback.map((f: any) => f.text), ["wrong color", "also add a key"], ); + assert.equal(updated.userFeedback[0].postId, s.id); + assert.equal(updated.userFeedback[0].postTitle, "Doc"); + assert.equal(updated.userFeedback[0].surfaceId, s.id); assert.equal(updated.userFeedback[0].surfaceTitle, "Doc"); // delivered once — the next write is clean @@ -2061,7 +2112,10 @@ test("POST /api/posts accepts a surfaces body and aliases /api/surfaces reads", assert.equal(res.status, 201); const created = (await res.json()) as any; assert.ok(created.id && created.sessionId); - assert.deepEqual(created.kinds, ["html"]); + assert.deepEqual( + created.surfaces.map((s: any) => s.kind), + ["html"], + ); // GET /api/posts/:id is identical to GET /api/surfaces/:id const viaPosts = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; @@ -2217,6 +2271,99 @@ test("GET /api/sessions/:id/posts mirrors /surfaces", async () => { assert.equal(viaPosts.length, 1); }); +test("GET /api/sessions/:id/posts lists lean surfaces with ids and omitted html bodies", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Listed", + surfaces: [ + { kind: "html", html: "

heavy

" }, + { kind: "markdown", markdown: "# shipped" }, + ], + }), + ) + ).json()) as any; + + const list = (await ( + await app.request(`/api/sessions/${created.sessionId}/posts`) + ).json()) as any[]; + assert.equal(list.length, 1); + assert.ok(Array.isArray(list[0].surfaces), "canonical key is surfaces"); + assert.deepEqual( + list[0].surfaces.map((p: any) => ({ id: typeof p.id, kind: p.kind })), + [ + { id: "string", kind: "html" }, + { id: "string", kind: "markdown" }, + ], + ); + assert.ok(!("html" in list[0].surfaces[0]), "elided html body key is absent"); + assert.equal(list[0].surfaces[1].markdown, "# shipped"); + assert.deepEqual(list[0].parts, list[0].surfaces, "legacy parts aliases surfaces"); +}); + +test("read responses expose derived surface indexes and renumber after edits", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Indexed", + surfaces: [ + { kind: "html", html: "

first

" }, + { kind: "markdown", markdown: "# second" }, + { kind: "terminal", text: "third" }, + ], + }), + ) + ).json()) as any; + await app.request(`/api/posts/${created.id}`, { + ...json({ title: "Indexed v2" }), + method: "PUT", + }); + + const detail = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.deepEqual( + detail.surfaces.map((p: any) => ({ index: p.index, kind: p.kind })), + [ + { index: 0, kind: "html" }, + { index: 1, kind: "markdown" }, + { index: 2, kind: "terminal" }, + ], + ); + assert.deepEqual( + detail.history[0].surfaces.map((p: any) => ({ index: p.index, kind: p.kind })), + [ + { index: 0, kind: "html" }, + { index: 1, kind: "markdown" }, + { index: 2, kind: "terminal" }, + ], + ); + + const list = (await ( + await app.request(`/api/sessions/${created.sessionId}/posts`) + ).json()) as any[]; + assert.deepEqual( + list[0].surfaces.map((p: any) => ({ index: p.index, kind: p.kind })), + [ + { index: 0, kind: "html" }, + { index: 1, kind: "markdown" }, + { index: 2, kind: "terminal" }, + ], + ); + + await app.request(`/api/posts/${created.id}/surfaces/0`, { method: "DELETE" }); + const renumbered = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.deepEqual( + renumbered.surfaces.map((p: any) => ({ index: p.index, kind: p.kind })), + [ + { index: 0, kind: "markdown" }, + { index: 1, kind: "terminal" }, + ], + ); +}); + test("GET /session/:id/p/:postId serves the viewer shell", async () => { const app = makeApp(); const created = (await ( @@ -2255,6 +2402,11 @@ test("publish_post / update_post / list_posts MCP tools accept surfaces", async ).json()) as any; const payload = JSON.parse(published.result.content[0].text); assert.ok(payload.id && payload.sessionId); + assert.equal(payload.kinds, undefined); + assert.deepEqual( + payload.surfaces.map((s: any) => s.kind), + ["diff"], + ); // new tools emit the canonical /p/ path assert.ok(payload.url.includes(`/p/${payload.id}`)); const full = (await (await app.request(`/api/posts/${payload.id}`)).json()) as any; @@ -2272,6 +2424,11 @@ test("publish_post / update_post / list_posts MCP tools accept surfaces", async ).json()) as any; const upPayload = JSON.parse(updated.result.content[0].text); assert.equal(upPayload.version, 2); + assert.equal(upPayload.kinds, undefined); + assert.deepEqual( + upPayload.surfaces.map((s: any) => s.kind), + ["html"], + ); assert.ok(upPayload.url.includes(`/p/${payload.id}`)); // list_posts scoped to the session @@ -2287,6 +2444,10 @@ test("publish_post / update_post / list_posts MCP tools accept surfaces", async const rows = JSON.parse(listed.result.content[0].text); assert.equal(rows.length, 1); assert.equal(rows[0].id, payload.id); + assert.equal(rows[0].kinds, undefined); + assert.equal(rows[0].parts, undefined); + assert.deepEqual(rows[0].surfaces, [{ id: upPayload.surfaces[0].id, kind: "html", index: 0 }]); + assert.deepEqual(Object.keys(rows[0].surfaces[0]).sort(), ["id", "index", "kind"]); }); test("reply_to_user MCP tool accepts postId (and legacy surfaceId)", async () => { @@ -2679,7 +2840,10 @@ test("POST /api/posts/:id/surfaces appends a surface", async () => { ); assert.equal(res.status, 200); const updated = (await res.json()) as any; - assert.deepEqual(updated.kinds, ["html", "markdown"]); + assert.deepEqual( + updated.surfaces.map((s: any) => s.kind), + ["html", "markdown"], + ); const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; assert.equal(full.surfaces.length, 2); @@ -3079,7 +3243,7 @@ test("mcp tools/list includes the new per-surface tools", async () => { assert.ok(names.includes("reorder_surfaces")); }); -test("mcp get_post fetches a single post with surface ids via HTTP MCP", async () => { +test("mcp get_post fetches full indexed post detail via HTTP MCP", async () => { const app = makeApp(); const pub = (await ( await app.request( @@ -3097,6 +3261,16 @@ test("mcp get_post fetches a single post with surface ids via HTTP MCP", async ( ) ).json()) as any; const postId = JSON.parse(pub.result.content[0].text).id; + await app.request(`/api/posts/${postId}`, { + ...json({ + title: "GetPost v2", + surfaces: [ + { kind: "html", html: "

a2

" }, + { kind: "markdown", markdown: "# b2" }, + ], + }), + method: "PUT", + }); const res = (await ( await app.request( @@ -3110,12 +3284,24 @@ test("mcp get_post fetches a single post with surface ids via HTTP MCP", async ( assert.equal(res.result.isError, undefined); const post = JSON.parse(res.result.content[0].text); assert.equal(post.id, postId); - assert.equal(post.title, "GetPost"); + assert.equal(post.title, "GetPost v2"); assert.equal(post.surfaces.length, 2); assert.equal(post.surfaces[0].kind, "html"); + assert.equal(post.surfaces[0].index, 0); + assert.equal(post.surfaces[0].html, "

a2

"); assert.equal(post.surfaces[1].kind, "markdown"); + assert.equal(post.surfaces[1].index, 1); + assert.equal(post.surfaces[1].markdown, "# b2"); assert.ok(post.surfaces[0].id, "surface ids are present"); assert.ok(post.surfaces[1].id); + assert.deepEqual( + post.history[0].surfaces.map((s: any) => ({ kind: s.kind, index: s.index })), + [ + { kind: "html", index: 0 }, + { kind: "markdown", index: 1 }, + ], + ); + assert.equal(post.history[0].surfaces[0].html, "

a

"); }); test("mcp tools/list includes get_post", async () => { diff --git a/test/cli.test.ts b/test/cli.test.ts index b6578aa..1528855 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -79,6 +79,8 @@ const post = (url: string, body: unknown) => body: JSON.stringify(body), }).then((r) => r.json() as Promise); +const surfaceKinds = (out: any) => out.surfaces.map((s: any) => s.kind); + // --- version --- for (const flag of ["--version", "-V", "version"]) { @@ -519,7 +521,7 @@ function cli(server: { url: string; session: { id: string } }, ...args: string[] // --- publish (html + combined surfaces) ----------------------------------- -test("publish posts an html file and prints id + url + kinds", async () => { +test("publish posts an html file and prints id + url + surface metadata", async () => { const server = await serveSession(); try { const file = tmpFile("card.html", "

hello

"); @@ -528,7 +530,7 @@ test("publish posts an html file and prints id + url + kinds", async () => { const out = JSON.parse(stdout); assert.equal(out.title, "Card"); assert.equal(out.sessionId, server.session.id); - assert.deepEqual(out.kinds, ["html"]); + assert.deepEqual(surfaceKinds(out), ["html"]); assert.equal(out.url, `${server.url}/s/${out.id}`); assert.equal(out.version, 1); } finally { @@ -549,7 +551,7 @@ test("publish reads html from stdin with '-'", async () => { ); assert.equal(code, 0); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["html"]); + assert.deepEqual(surfaceKinds(out), ["html"]); const full = (await fetch(`${server.url}/api/surfaces/${out.id}`).then((r) => r.json())) as any; assert.equal(full.surfaces[0].html, "

piped

"); } finally { @@ -581,7 +583,7 @@ test("publish combines html with --md, --code, --terminal, --mermaid surfaces in assert.equal(exit, 0); const out = JSON.parse(stdout); // Surfaces appear in the order their flags were passed on the command line. - assert.deepEqual(out.kinds, ["html", "markdown", "code", "terminal", "mermaid"]); + assert.deepEqual(surfaceKinds(out), ["html", "markdown", "code", "terminal", "mermaid"]); } finally { await server.close(); } @@ -602,8 +604,8 @@ test("publish surface order follows flag order, not a fixed sequence", async () assert.equal(b.code, 0); const outA = JSON.parse(a.stdout); const outB = JSON.parse(b.stdout); - assert.deepEqual(outA.kinds, ["html", "code", "mermaid", "markdown"]); - assert.deepEqual(outB.kinds, ["html", "markdown", "mermaid", "code"]); + assert.deepEqual(surfaceKinds(outA), ["html", "code", "mermaid", "markdown"]); + assert.deepEqual(surfaceKinds(outB), ["html", "markdown", "mermaid", "code"]); } finally { await server.close(); } @@ -626,7 +628,7 @@ test("publish surfaces with --terminal before --md produces terminal-then-markdo ); assert.equal(exit, 0); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["html", "terminal", "markdown"]); + assert.deepEqual(surfaceKinds(out), ["html", "terminal", "markdown"]); } finally { await server.close(); } @@ -653,7 +655,7 @@ test("publish repeats a surface flag to add several of the same kind, in order", assert.equal(exit, 0); const out = JSON.parse(stdout); // Two diff surfaces appear, with the code surface between them in argv order. - assert.deepEqual(out.kinds, ["html", "diff", "code", "diff"]); + assert.deepEqual(surfaceKinds(out), ["html", "diff", "code", "diff"]); const full = (await fetch(`${server.url}/api/surfaces/${out.id}`).then((r) => r.json())) as any; const diffs = full.surfaces.filter((s: any) => s.kind === "diff"); assert.equal(diffs.length, 2); @@ -673,7 +675,7 @@ test("publish --code infers the language from the filename", async () => { const code = tmpFile("app.py", "print('hi')"); const { stdout } = await cli(server, "publish", html, "--code", code); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["html", "code"]); + assert.deepEqual(surfaceKinds(out), ["html", "code"]); const full = (await fetch(`${server.url}/api/surfaces/${out.id}`).then((r) => r.json())) as any; const codeSurface = full.surfaces.find((s: any) => s.kind === "code"); assert.equal(codeSurface.language, "python"); @@ -703,7 +705,7 @@ test("publish --diff with --layout split carries the layout on the diff surface" const patch = tmpFile("p.patch", "--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-old\n+new\n"); const { stdout } = await cli(server, "publish", html, "--diff", patch, "--layout", "split"); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["html", "diff"]); + assert.deepEqual(surfaceKinds(out), ["html", "diff"]); const full = (await fetch(`${server.url}/api/surfaces/${out.id}`).then((r) => r.json())) as any; assert.equal(full.surfaces.find((s: any) => s.kind === "diff").layout, "split"); } finally { @@ -719,7 +721,7 @@ test("diff publishes a diff-only post from a patch", async () => { const patch = tmpFile("p.patch", "--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-old\n+new\n"); const { code, stdout } = await cli(server, "diff", patch, "--title", "Fix"); assert.equal(code, 0); - assert.deepEqual(JSON.parse(stdout).kinds, ["diff"]); + assert.deepEqual(surfaceKinds(JSON.parse(stdout)), ["diff"]); } finally { await server.close(); } @@ -731,7 +733,7 @@ test("markdown publishes a markdown-only post", async () => { const md = tmpFile("m.md", "# hello\n\nbody"); const { code, stdout } = await cli(server, "markdown", md); assert.equal(code, 0); - assert.deepEqual(JSON.parse(stdout).kinds, ["markdown"]); + assert.deepEqual(surfaceKinds(JSON.parse(stdout)), ["markdown"]); } finally { await server.close(); } @@ -826,7 +828,7 @@ test("mermaid publishes a mermaid-only post", async () => { const m = tmpFile("d.mmd", "graph TD; A-->B"); const { code, stdout } = await cli(server, "mermaid", m); assert.equal(code, 0); - assert.deepEqual(JSON.parse(stdout).kinds, ["mermaid"]); + assert.deepEqual(surfaceKinds(JSON.parse(stdout)), ["mermaid"]); } finally { await server.close(); } @@ -842,7 +844,7 @@ test("trace publishes a trace asset post", async () => { const { code, stdout } = await cli(server, "trace", trace, "--title", "Trace"); assert.equal(code, 0); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["trace"]); + assert.deepEqual(surfaceKinds(out), ["trace"]); const full = (await fetch(`${server.url}/api/posts/${out.id}`).then((r) => r.json())) as any; assert.equal(full.surfaces[0].kind, "trace"); assert.ok(full.surfaces[0].assetId); @@ -896,7 +898,7 @@ test("surface add appends a markdown surface to an existing post", async () => { const { code, stdout } = await cli(server, "surface", "add", id, "--md", md); assert.equal(code, 0); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["html", "markdown"]); + assert.deepEqual(surfaceKinds(out), ["html", "markdown"]); const full = (await fetch(`${server.url}/api/posts/${id}`).then((r) => r.json())) as any; assert.equal(full.surfaces[1].markdown, "# appended"); @@ -961,7 +963,7 @@ test("surface remove deletes a surface by index", async () => { const { code, stdout } = await cli(server, "surface", "remove", id, "1"); assert.equal(code, 0); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["html"]); + assert.deepEqual(surfaceKinds(out), ["html"]); } finally { await server.close(); } @@ -1244,7 +1246,7 @@ test("image uploads bytes and publishes an image post", async () => { const { code, stdout } = await cli(server, "image", png, "--title", "Shot", "--caption", "hi"); assert.equal(code, 0); const out = JSON.parse(stdout); - assert.deepEqual(out.kinds, ["image"]); + assert.deepEqual(surfaceKinds(out), ["image"]); const full = (await fetch(`${server.url}/api/surfaces/${out.id}`).then((r) => r.json())) as any; assert.equal(full.surfaces[0].caption, "hi"); assert.ok(full.surfaces[0].assetId); diff --git a/test/surfaces-recent.test.ts b/test/surfaces-recent.test.ts index fe83377..3b7815f 100644 --- a/test/surfaces-recent.test.ts +++ b/test/surfaces-recent.test.ts @@ -80,7 +80,11 @@ test("GET /api/surfaces/recent returns posts newest-first across sessions", asyn assert.equal(top.agent, "amp"); assert.equal(top.title, "third"); assert.deepEqual(top.partKinds, ["html"]); - assert.ok(Array.isArray(top.parts)); + assert.ok(Array.isArray(top.surfaces)); + assert.deepEqual(top.surfaces, [ + { id: top.surfaces[0].id, kind: "html", html: "

3

", index: 0 }, + ]); + assert.deepEqual(top.parts, top.surfaces); const middle = feed[1]; assert.equal(middle.sessionId, b.id); @@ -155,7 +159,12 @@ test("GET /api/surfaces/recent caps oversized text parts and flags truncation", }); const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; - const parts = feed[0].parts; + assert.deepEqual(feed[0].parts, feed[0].surfaces); + assert.deepEqual( + feed[0].surfaces.map((p: any) => p.index), + [0, 1, 2, 3, 4], + ); + const parts = feed[0].surfaces; const html = parts.find((p: any) => p.kind === "html"); assert.equal(html.html.length, 8_000); // PART_TEXT_CAP @@ -205,12 +214,28 @@ test("GET /api/surfaces/recent leaves image parts as plain assetId refs", async }); const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; - const img = feed[0].parts.find((p: any) => p.kind === "image"); + assert.deepEqual(feed[0].parts, feed[0].surfaces); + const img = feed[0].surfaces.find((p: any) => p.kind === "image"); assert.equal(img.assetId, upload.id); assert.equal(img.alt, "a shot"); + assert.equal(img.index, 0); assert.equal(img.truncated, undefined); }); +test("GET /api/posts/recent aliases /api/surfaces/recent with identical auth", async () => { + const app = makeApp(); + const s = await createSession(app, "amp", "Session"); + await publish(app, { session: s.id, parts: [{ kind: "html", html: "

x

" }] }); + + const viaSurfaces = (await (await app.request("/api/surfaces/recent")).json()) as any[]; + const viaPosts = (await (await app.request("/api/posts/recent")).json()) as any[]; + assert.deepEqual(viaPosts, viaSurfaces); + + const guarded = makeApp("secret", { publicRead: "session" }); + assert.equal((await guarded.request("/api/surfaces/recent")).status, 401); + assert.equal((await guarded.request("/api/posts/recent")).status, 401); +}); + test("GET /api/surfaces/recent is auth-gated exactly like /api/sessions", async () => { // With an auth token configured, both routes require it. const guarded = makeApp("secret");