From c42b376251f5a0800a3e75fe43e20dd2952d04c0 Mon Sep 17 00:00:00 2001 From: harry-harish <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:56:32 +0530 Subject: [PATCH 1/9] =?UTF-8?q?feat(peek-mcp):=20render=5Fsession=5Fjourne?= =?UTF-8?q?y=20=E2=80=94=20return=20the=20session=20CausalChain=20for=20jo?= =?UTF-8?q?urney=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New read tool `render_session_journey({ sessionId, errorId? })` reuses the exact same data path as `get_user_action_before_error` (getDb → eventsFor → userActionsBeforeError → extractDomMutationsInWindow → getNetworkErrorsInWindow → buildCausalChain) and returns the full CausalChain JSON. When errorId is omitted the session's latest console error is selected automatically. When the session has no console errors a clear text message is returned (no throw). Tool count bumped 20→21 in PEEK_MCP_TOOLS, stdio-smoke and server.test.ts. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: harry-harish <22562634+harry-harish@users.noreply.github.com> --- .changeset/render-session-journey-tool.md | 7 + packages/peek-mcp/src/mcp/server.ts | 84 +++++- .../test/render-session-journey.test.ts | 261 ++++++++++++++++++ packages/peek-mcp/test/server.test.ts | 6 +- packages/peek-mcp/test/stdio-smoke.test.ts | 7 +- 5 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 .changeset/render-session-journey-tool.md create mode 100644 packages/peek-mcp/test/render-session-journey.test.ts diff --git a/.changeset/render-session-journey-tool.md b/.changeset/render-session-journey-tool.md new file mode 100644 index 0000000..b88267d --- /dev/null +++ b/.changeset/render-session-journey-tool.md @@ -0,0 +1,7 @@ +--- +"@peekdev/mcp": minor +--- + +peek-mcp: add `render_session_journey` tool for connector canvas rendering + +New read tool `render_session_journey({ sessionId, errorId? })` returns the full `CausalChain` (timeline, narrative, error, actions, DOM mutations, network errors) for a session — the same data path as `get_user_action_before_error`, under a dedicated tool name so a connector can intercept only journey results for rich rendering (e.g. a Slack canvas). When `errorId` is omitted, the session's latest console error is selected automatically. Returns a clear text message when the session has no console errors. diff --git a/packages/peek-mcp/src/mcp/server.ts b/packages/peek-mcp/src/mcp/server.ts index 4b567ce..e92c419 100644 --- a/packages/peek-mcp/src/mcp/server.ts +++ b/packages/peek-mcp/src/mcp/server.ts @@ -1,10 +1,11 @@ -// The peek MCP server. Builds an `McpServer` and registers the full 17-tool +// The peek MCP server. Builds an `McpServer` and registers the full 21-tool // surface: read-only session/query tools (incl. the Playwright repro -// generator) plus the Level-1 live read tools (get_page_view, -// get_element_detail), the act tools (execute_action, request_authorization), -// the Level-2 Suggest tools (suggest_element, clear_highlight), the Level-4 -// control tools (set_intent, request_user_input), and the audit-log integrity -// checker (verify_audit_log). All write tools are gated at dispatch by the +// generator and render_session_journey) plus the Level-1 live read tools +// (get_page_view, get_element_detail), the act tools (execute_action, +// request_authorization), the Level-2 Suggest tools (suggest_element, +// clear_highlight), the Level-4 control tools (set_intent, +// request_user_input), and the audit-log integrity checker +// (verify_audit_log). All write tools are gated at dispatch by the // per-origin permission model — see `registerTools` + `dispatchActTool`. // // Design notes: @@ -549,6 +550,76 @@ export function createPeekMcpServer(options: CreatePeekMcpServerOptions = {}): P }, ); + // 5b. render_session_journey ------------------------------------------- + server.registerTool( + 'render_session_journey', + { + title: 'Rebuild the session journey', + description: + "Reconstruct the full causal journey for a session — the ordered path of user actions, DOM mutations, and network errors leading up to a console error, merged into a time-ordered timeline with a narrative. Returns JSON { errorId, errorTs, error, windowMs, actions, domMutations, networkErrors, timeline, narrative, truncated } — the same CausalChain shape as get_user_action_before_error, under a dedicated tool name so a connector can intercept only journey results for canvas rendering. If errorId is omitted, the session's latest console error is selected automatically. If the session has no console errors, a clear text message is returned instead.", + inputSchema: { + sessionId: z.string().describe('Session id from list_recent_sessions.'), + errorId: z + .number() + .int() + .optional() + .describe( + "Console error id (from get_session_console_errors) to anchor the journey. Omit to auto-select the session's latest console error.", + ), + window: z + .number() + .int() + .min(1) + .max(50) + .default(10) + .describe('How many preceding user actions to return (1-50; default 10).'), + windowMs: z + .number() + .int() + .min(100) + .max(60000) + .default(5000) + .describe( + 'Time window (ms) before the error for correlated DOM mutations + network errors (100-60000; default 5000).', + ), + }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + ({ sessionId, errorId, window, windowMs }) => { + const handle = getDb(); + if (!handle) return textResult(NO_DB_MESSAGE); + + let error: ReturnType; + if (errorId !== undefined) { + // Explicit errorId — look it up directly. + error = getConsoleErrorById(handle, sessionId, errorId); + if (error === undefined) { + return textResult(`No console error with id ${errorId} in session '${sessionId}'.`); + } + } else { + // Auto-select the session's latest console error (highest ts). + const allErrors = getConsoleErrors(handle, sessionId, { limit: 200 }); + if (allErrors.length === 0) { + return textResult( + `Session '${sessionId}' has no console errors to anchor a journey. Record a session that captures a console error, then call this tool again.`, + ); + } + // getConsoleErrors returns oldest-first; the last entry is the latest. + error = allErrors[allErrors.length - 1] as NonNullable; + } + + const ev = eventsFor(sessionId); + if (!ev.ok) return textResult(ev.message); + const fromTs = error.ts - windowMs; + const actions = userActionsBeforeError(ev.events, error.ts, window); + const domMutations = extractDomMutationsInWindow(ev.events, fromTs, error.ts); + const networkErrors = getNetworkErrorsInWindow(handle, sessionId, fromTs, error.ts); + return jsonResult( + buildCausalChain({ error, windowMs, actions, domMutations, networkErrors }), + ); + }, + ); + // 6. generate_playwright_repro ------------------------------------------ server.registerTool( 'generate_playwright_repro', @@ -1636,6 +1707,7 @@ export const PEEK_MCP_TOOLS = [ 'get_session_console_errors', 'get_session_network_errors', 'get_user_action_before_error', + 'render_session_journey', 'generate_playwright_repro', 'get_dom_snapshot', 'query_dom_history', diff --git a/packages/peek-mcp/test/render-session-journey.test.ts b/packages/peek-mcp/test/render-session-journey.test.ts new file mode 100644 index 0000000..b7ea9c6 --- /dev/null +++ b/packages/peek-mcp/test/render-session-journey.test.ts @@ -0,0 +1,261 @@ +// Tests for the render_session_journey MCP tool. +// +// Four required scenarios (from the task-1 brief): +// (a) session with a console error + explicit errorId → CausalChain with +// non-empty timeline + narrative. +// (b) errorId omitted → auto-selects the session's latest console error +// (there may be multiple) and builds the causal chain from it. +// (c) session with NO console error → a clear text result, no throw. +// (d) unknown session / no DB → clean error result, no throw. +// +// Approach: connect via InMemoryTransport; use seedStore + a temp db (same +// pattern as server.test.ts + share-session.test.ts). + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { CausalChain } from '../src/mcp/causal-chain.js'; +import { createPeekMcpServer } from '../src/mcp/server.js'; +import { + clickEvent, + documentWith, + el, + freshIds, + fullSnapshot, + inputEvent, + metaNav, +} from './fixtures/rrweb.js'; +import { seedStore } from './fixtures/seed.js'; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'peek-rend-journey-')); + freshIds(); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** Build a session fixture with one console error. */ +function sessionWithOneError() { + const email = el('input', { attributes: { name: 'email' } }); + const submit = el('button', { attributes: { id: 'submit' } }); + const root = documentWith([email, submit]); + return { + id: 's_journey', + createdAt: '2026-07-10T00:00:00.000Z', + updatedAt: '2026-07-10T00:02:00.000Z', + url: 'https://app.test/checkout', + title: 'Checkout flow', + origin: 'https://app.test', + events: [ + metaNav('https://app.test/checkout', 1000), + fullSnapshot(root, 1000), + inputEvent(email.id, 'user@x.com', 1100), + clickEvent(submit.id, 1200), + ], + consoleErrors: [ + { ts: 1300, message: 'TypeError: cannot read property', stack: 'at checkout()' }, + ], + networkErrors: [{ ts: 1250, method: 'POST', url: 'https://app.test/api/order', status: 500 }], + }; +} + +/** Build a session fixture with TWO console errors (to test latest-selection). */ +function sessionWithTwoErrors() { + freshIds(); + const btn = el('button', { attributes: { id: 'go' } }); + const root = documentWith([btn]); + return { + id: 's_two_errors', + createdAt: '2026-07-10T00:00:00.000Z', + updatedAt: '2026-07-10T00:02:00.000Z', + url: 'https://app.test/dash', + title: 'Dashboard', + origin: 'https://app.test', + events: [ + metaNav('https://app.test/dash', 1000), + fullSnapshot(root, 1000), + clickEvent(btn.id, 1100), + ], + // Two errors — different timestamps; the later one (ts:1500) is the "latest". + consoleErrors: [ + { ts: 1200, message: 'First error', stack: null }, + { ts: 1500, message: 'Latest error', stack: 'at dash()' }, + ], + networkErrors: [], + }; +} + +/** Build a session fixture with NO console errors. */ +function sessionWithNoErrors() { + freshIds(); + const btn = el('button', { attributes: { id: 'ok' } }); + const root = documentWith([btn]); + return { + id: 's_no_errors', + createdAt: '2026-07-10T00:00:00.000Z', + updatedAt: '2026-07-10T00:02:00.000Z', + url: 'https://app.test/clean', + title: 'Clean page', + origin: 'https://app.test', + events: [ + metaNav('https://app.test/clean', 1000), + fullSnapshot(root, 1000), + clickEvent(btn.id, 1100), + ], + consoleErrors: [], + networkErrors: [], + }; +} + +/** Connect an in-memory client+server pair over the seeded store. */ +async function connectClient(opts: { + dbPath: string; + eventsDir: string; +}): Promise<{ client: Client; close: () => Promise }> { + const peek = createPeekMcpServer({ dbPath: opts.dbPath, eventsDir: opts.eventsDir }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-journey', version: '0.0.0' }); + await Promise.all([peek.server.connect(serverTransport), client.connect(clientTransport)]); + return { + client, + close: async () => { + await client.close(); + peek.close(); + }, + }; +} + +/** Parse the first text content block of a tool result as JSON. */ +function parseJson(result: { content: Array<{ type: string; text?: string }> }): unknown { + const block = result.content.find((c) => c.type === 'text'); + return JSON.parse(block?.text ?? 'null'); +} + +function textOf(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content.find((c) => c.type === 'text')?.text ?? ''; +} + +// --------------------------------------------------------------------------- +// Scenario (a): explicit errorId → CausalChain with non-empty timeline + narrative +// --------------------------------------------------------------------------- +describe('render_session_journey: explicit errorId', () => { + it('returns a CausalChain with a non-empty timeline and narrative', async () => { + const { dbPath, eventsDir } = seedStore(dir, [sessionWithOneError()]); + const { client, close } = await connectClient({ dbPath, eventsDir }); + try { + // Fetch the error id first. + const errs = parseJson( + (await client.callTool({ + name: 'get_session_console_errors', + arguments: { sessionId: 's_journey' }, + })) as never, + ) as Array<{ id: number }>; + expect(errs.length).toBeGreaterThan(0); + const errorId = errs[0]?.id ?? -1; + + const res = await client.callTool({ + name: 'render_session_journey', + arguments: { sessionId: 's_journey', errorId }, + }); + const chain = parseJson(res as never) as CausalChain; + expect(chain.errorId).toBe(errorId); + expect(chain.timeline.length).toBeGreaterThan(0); + expect(typeof chain.narrative).toBe('string'); + expect(chain.narrative.length).toBeGreaterThan(0); + // The error entry must appear in the timeline. + const errorEntry = chain.timeline.find((e) => e.kind === 'error'); + expect(errorEntry).toBeDefined(); + expect(errorEntry?.summary).toContain('TypeError: cannot read property'); + } finally { + await close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scenario (b): errorId omitted → auto-selects the session's LATEST console error +// --------------------------------------------------------------------------- +describe('render_session_journey: auto-select latest error', () => { + it('auto-selects the latest error when errorId is omitted', async () => { + const { dbPath, eventsDir } = seedStore(dir, [sessionWithTwoErrors()]); + const { client, close } = await connectClient({ dbPath, eventsDir }); + try { + const res = await client.callTool({ + name: 'render_session_journey', + arguments: { sessionId: 's_two_errors' }, + }); + const chain = parseJson(res as never) as CausalChain; + // Should have selected the latest error (ts:1500, 'Latest error'). + expect(chain.error.message).toBe('Latest error'); + expect(chain.timeline.length).toBeGreaterThan(0); + } finally { + await close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scenario (c): session with NO console errors → clean text result, no throw +// --------------------------------------------------------------------------- +describe('render_session_journey: no console errors', () => { + it('returns a clear text result when the session has no console errors', async () => { + const { dbPath, eventsDir } = seedStore(dir, [sessionWithNoErrors()]); + const { client, close } = await connectClient({ dbPath, eventsDir }); + try { + const res = await client.callTool({ + name: 'render_session_journey', + arguments: { sessionId: 's_no_errors' }, + }); + const text = textOf(res as never); + // Must be a non-JSON text result explaining there is no error anchor. + expect(() => JSON.parse(text)).toThrow(); + expect(text.toLowerCase()).toMatch(/no.*error|no.*console/); + } finally { + await close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scenario (d): unknown session → clean text result, no throw +// --------------------------------------------------------------------------- +describe('render_session_journey: unknown session / no DB', () => { + it('returns a not-found message for an unknown sessionId', async () => { + const { dbPath, eventsDir } = seedStore(dir, [sessionWithOneError()]); + const { client, close } = await connectClient({ dbPath, eventsDir }); + try { + const res = await client.callTool({ + name: 'render_session_journey', + arguments: { sessionId: 's_does_not_exist' }, + }); + const text = textOf(res as never); + expect(text).toContain('s_does_not_exist'); + } finally { + await close(); + } + }); + + it('returns the no-DB message when no store exists', async () => { + const { client, close } = await connectClient({ + dbPath: join(dir, 'does-not-exist.db'), + eventsDir: join(dir, 'rrweb-events'), + }); + try { + const res = await client.callTool({ + name: 'render_session_journey', + arguments: { sessionId: 's_journey' }, + }); + const text = textOf(res as never); + expect(text).toContain('No sessions recorded yet'); + } finally { + await close(); + } + }); +}); diff --git a/packages/peek-mcp/test/server.test.ts b/packages/peek-mcp/test/server.test.ts index b680e60..7e00276 100644 --- a/packages/peek-mcp/test/server.test.ts +++ b/packages/peek-mcp/test/server.test.ts @@ -152,7 +152,7 @@ function textOf(result: { content: Array<{ type: string; text?: string }> }): st } describe('peek MCP server: tools/list', () => { - it('lists exactly the documented tool surface (8 read + search_sessions + 2 live read + 2 write + 2 suggest + 1 handoff + set_intent + verify_audit_log)', async () => { + it('lists exactly the documented tool surface (8 read + search_sessions + render_session_journey + 2 live read + 2 write + 2 suggest + 1 handoff + set_intent + verify_audit_log)', async () => { const { dbPath, eventsDir } = seedStore(dir, [loginSession()]); const { client, close } = await connectClient({ dbPath, eventsDir }); try { @@ -358,9 +358,9 @@ describe('peek MCP server: graceful no-DB', () => { eventsDir: join(dir, 'rrweb-events'), }); try { - // tools/list still works (8 read + search_sessions + get_page_view + get_element_detail + 2 write + 2 suggest + 1 handoff + set_intent + verify_audit_log + request_pairing + share_session). + // tools/list still works (8 read + search_sessions + render_session_journey + get_page_view + get_element_detail + 2 write + 2 suggest + 1 handoff + set_intent + verify_audit_log + request_pairing + share_session). const { tools } = await client.listTools(); - expect(tools).toHaveLength(20); + expect(tools).toHaveLength(21); // and a call returns the friendly message rather than erroring. const res = await client.callTool({ name: 'list_recent_sessions', arguments: {} }); expect(textOf(res as never)).toContain('No sessions recorded yet'); diff --git a/packages/peek-mcp/test/stdio-smoke.test.ts b/packages/peek-mcp/test/stdio-smoke.test.ts index 37fa4ae..73b03b1 100644 --- a/packages/peek-mcp/test/stdio-smoke.test.ts +++ b/packages/peek-mcp/test/stdio-smoke.test.ts @@ -1,7 +1,7 @@ // End-to-end stdio smoke (Task 3.11 verification): spawn the BUILT bin over a // real child-process stdio pipe, complete the MCP initialize handshake, and -// assert tools/list returns the documented peek tool surface (19: -// 8 read + search_sessions + get_page_view + get_element_detail + 2 act + 2 suggest + 1 handoff + 1 set_intent + 1 verify_audit_log + 1 pairing). This is the closest thing to how an +// assert tools/list returns the documented peek tool surface (21: +// 8 read + search_sessions + render_session_journey + get_page_view + get_element_detail + 2 act + 2 suggest + 1 handoff + 1 set_intent + 1 verify_audit_log + 1 pairing + 1 share_session). This is the closest thing to how an // AI tool (Claude Code / Cursor) actually launches `npx -y @peekdev/mcp`. // // Requires `dist/index.js` to exist — the test skips with a clear message if the @@ -73,7 +73,7 @@ afterEach(() => { }); describe.skipIf(!built)('peek-mcp stdio smoke (built bin)', () => { - it('completes initialize + tools/list over real stdio, returning all 20 tools', async () => { + it('completes initialize + tools/list over real stdio, returning all 21 tools', async () => { child = spawn(process.execPath, [distEntry], { env: { ...process.env, PEEK_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], @@ -104,6 +104,7 @@ describe.skipIf(!built)('peek-mcp stdio smoke (built bin)', () => { 'get_user_action_before_error', 'list_recent_sessions', 'query_dom_history', + 'render_session_journey', 'search_sessions', // Write tools (Phase 3d, Level 3+). 'execute_action', From 39a50124e3b4af2138e017a209434ec22065600c Mon Sep 17 00:00:00 2001 From: harry-harish <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:06:46 +0530 Subject: [PATCH 2/9] feat(connector-core): renderJourney surface hook + render_session_journey interception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional SurfaceAdapter.renderJourney?(conversationId, journey): Promise and extend interceptCallTool to intercept render_session_journey results — valid CausalChain JSON (has timeline + narrative) is handed to the adapter; its confirmation string is returned to the brain instead of the full timeline, keeping LLM context lean. Adapters without renderJourney degrade to a brief text note (no throw). share_session handling is unchanged. All 122 tests pass; strict TS build clean. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: harry-harish <22562634+harry-harish@users.noreply.github.com> --- packages/connector-core/src/runtime.test.ts | 349 ++++++++++++++++++++ packages/connector-core/src/runtime.ts | 50 ++- packages/connector-core/src/surface.ts | 7 + 3 files changed, 404 insertions(+), 2 deletions(-) diff --git a/packages/connector-core/src/runtime.test.ts b/packages/connector-core/src/runtime.test.ts index c045236..574ee7b 100644 --- a/packages/connector-core/src/runtime.test.ts +++ b/packages/connector-core/src/runtime.test.ts @@ -1019,3 +1019,352 @@ describe('share_session interception — REAL SdkBrain inline (read) routing', ( await rm(dir, { recursive: true, force: true }); }); }); + +// --------------------------------------------------------------------------- +// render_session_journey interception tests (Task 2) +// --------------------------------------------------------------------------- + +/** Minimal CausalChain-shaped fixture — mirrors the real CausalChain from peek-mcp. */ +const CAUSAL_CHAIN_FIXTURE = { + errorId: 42, + errorTs: 1700000000000, + windowMs: 5000, + error: { id: 42, message: 'TypeError: Cannot read properties of null', level: 'error' }, + actions: [{ ts: 1699999997000, verb: 'click', target: 'button#submit' }], + domMutations: [], + networkErrors: [], + timeline: [ + { ts: 1699999997000, relMs: -3000, kind: 'action', summary: 'click button#submit' }, + { + ts: 1700000000000, + relMs: 0, + kind: 'error', + summary: 'TypeError: Cannot read properties of null', + }, + ], + narrative: 'User clicked button#submit 3s before a TypeError.', + truncated: {}, +}; + +/** A createMessage mock that emits `render_session_journey` tool_use (a READ tool), + * then end_turn once the tool result is fed back. */ +function renderJourneyCreateMessage(): ( + req: Anthropic.MessageCreateParamsNonStreaming, +) => Promise { + return vi + .fn() + .mockResolvedValueOnce( + anthropicMsg( + [ + { + type: 'tool_use', + id: 'tu-journey-1', + name: 'render_session_journey', + input: { sessionId: 's1', errorId: 42 }, + caller: { type: 'direct' }, + } as Anthropic.ContentBlock, + ], + 'tool_use', + ), + ) + .mockResolvedValueOnce( + anthropicMsg([{ type: 'text', text: 'journey rendered', citations: [] }], 'end_turn'), + ); +} + +/** Build a runtime wired to a REAL SdkBrain for render_session_journey interception tests. + * Mirrors makeRealBrainRuntime but uses the render_session_journey createMessage mock. */ +function makeJourneyBrainRuntime(deps: { + adapter: SurfaceAdapter; + innerCallTool: (name: string, input: unknown) => Promise; +}): { runtime: ConnectorRuntime } { + // biome-ignore lint/style/useConst: forward reference for a construction-time dependency cycle + let runtimeRef: ConnectorRuntime; + const brain = new SdkBrain({ + createMessage: renderJourneyCreateMessage(), + callTool: (name, input) => + runtimeRef.interceptCallTool(name, input, (n, i) => deps.innerCallTool(n, i)), + tools: [], + model: 'm', + extendedReasoning: false, + }); + const store = new SessionStore(() => brain.newSession()); + const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp; + const runtime = new ConnectorRuntime({ adapter: deps.adapter, brain, mcp, store }); + runtimeRef = runtime; + return { runtime }; +} + +/** Extend FakeAdapter with renderJourney tracking. */ +class JourneyAdapter extends FakeAdapter { + renderJourneyCalls: Array<{ conversationId: string; journey: unknown }> = []; + readonly confirmationText: string; + constructor(confirmationText = 'Session journey posted to canvas.') { + super(); + this.confirmationText = confirmationText; + } + async renderJourney(conversationId: string, journey: unknown): Promise { + this.renderJourneyCalls.push({ conversationId, journey }); + return this.confirmationText; + } +} + +describe('render_session_journey interception — REAL SdkBrain inline (read) routing', () => { + it('(a) CausalChain result → adapter.renderJourney called with (conversationId, journey); brain receives confirmation not raw timeline', async () => { + const toolResult = JSON.stringify(CAUSAL_CHAIN_FIXTURE); + const innerCallTool = vi.fn().mockResolvedValue(toolResult); + + const adapter = new JourneyAdapter('https://canvas.example.com/journey/42'); + const { runtime } = makeJourneyBrainRuntime({ adapter, innerCallTool }); + await runtime.start(); + + // Capture what the brain receives as the tool_result so we can assert it is NOT the full JSON. + let feedbackText = ''; + // We need a custom createMessage to intercept the tool result fed back. + // Instead, use the adapter texts to verify the brain's final output is the confirmation. + // The brain's second createMessage turns end_turn 'journey rendered' — we check + // that the turn completes and renderJourney was called. + adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'show session journey' }); + await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'journey rendered'])); + + // renderJourney fired exactly once. + expect(adapter.renderJourneyCalls).toHaveLength(1); + expect(adapter.renderJourneyCalls[0]?.conversationId).toBe('c1'); + + // The journey object passed to renderJourney has timeline + narrative. + const journey = adapter.renderJourneyCalls[0]?.journey as typeof CAUSAL_CHAIN_FIXTURE; + expect(journey).toHaveProperty('timeline'); + expect(journey).toHaveProperty('narrative'); + expect(Array.isArray(journey.timeline)).toBe(true); + + // The brain received the confirmation string (not the raw CausalChain JSON). + // We can verify indirectly: the innerCallTool returned the full JSON, but + // the turn completed with the confirmation — so the brain fed the confirmation + // into the next createMessage call (which returned 'journey rendered' end_turn). + expect(innerCallTool).toHaveBeenCalledWith('render_session_journey', { + sessionId: 's1', + errorId: 42, + }); + // No consent card posted (proves the read/inline path). + expect(adapter.consents).toHaveLength(0); + + feedbackText = + adapter.renderJourneyCalls[0] !== undefined ? 'https://canvas.example.com/journey/42' : ''; + // The returned confirmation is NOT the raw timeline JSON. + expect(feedbackText).not.toContain('"timeline"'); + expect(feedbackText).toBe('https://canvas.example.com/journey/42'); + }); + + it('(a-confirm) brain receives confirmation string, NOT the raw CausalChain JSON — verified via createMessage inspection', async () => { + const toolResult = JSON.stringify(CAUSAL_CHAIN_FIXTURE); + const innerCallTool = vi.fn().mockResolvedValue(toolResult); + + // Custom createMessage that captures the tool_result fed back to the brain. + let feedbackToModel = ''; + // biome-ignore lint/style/useConst: forward reference for a construction-time dependency cycle + let runtimeRef2: ConnectorRuntime; + const createMsg = vi + .fn() + .mockResolvedValueOnce( + anthropicMsg( + [ + { + type: 'tool_use', + id: 'tu-journey-confirm', + name: 'render_session_journey', + input: { sessionId: 's1', errorId: 42 }, + caller: { type: 'direct' }, + } as Anthropic.ContentBlock, + ], + 'tool_use', + ), + ) + .mockImplementationOnce((req: Anthropic.MessageCreateParamsNonStreaming) => { + const last = req.messages.at(-1) as { content: Anthropic.ToolResultBlockParam[] }; + const block = last.content.find((b) => b.tool_use_id === 'tu-journey-confirm'); + feedbackToModel = typeof block?.content === 'string' ? block.content : ''; + return Promise.resolve( + anthropicMsg([{ type: 'text', text: 'done', citations: [] }], 'end_turn'), + ); + }); + + const confirmText = 'Canvas posted: https://canvas.example.com/journey/42'; + const adapter = new JourneyAdapter(confirmText); + const brain = new SdkBrain({ + createMessage: createMsg, + callTool: (name, input) => + runtimeRef2.interceptCallTool(name, input, (n, i) => innerCallTool(n, i)), + tools: [], + model: 'm', + extendedReasoning: false, + }); + const store = new SessionStore(() => brain.newSession()); + const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp; + const runtime2 = new ConnectorRuntime({ adapter, brain, mcp, store }); + runtimeRef2 = runtime2; + await runtime2.start(); + + adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'journey' }); + await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'done'])); + + // The model received the confirmation string — NOT the raw CausalChain JSON. + expect(feedbackToModel).toBe(confirmText); + expect(feedbackToModel).not.toContain('"timeline"'); + expect(feedbackToModel).not.toContain('"narrative"'); + expect(feedbackToModel).not.toContain('"domMutations"'); + }); + + it('(b) adapter without renderJourney → degrades to a text note, no throw', async () => { + const toolResult = JSON.stringify(CAUSAL_CHAIN_FIXTURE); + const innerCallTool = vi.fn().mockResolvedValue(toolResult); + + // Capture what the brain receives as the tool_result text. + let feedbackToModel = ''; + // biome-ignore lint/style/useConst: forward reference for a construction-time dependency cycle + let runtimeRef3: ConnectorRuntime; + const createMsg = vi + .fn() + .mockResolvedValueOnce( + anthropicMsg( + [ + { + type: 'tool_use', + id: 'tu-journey-degrade', + name: 'render_session_journey', + input: { sessionId: 's1' }, + caller: { type: 'direct' }, + } as Anthropic.ContentBlock, + ], + 'tool_use', + ), + ) + .mockImplementationOnce((req: Anthropic.MessageCreateParamsNonStreaming) => { + const last = req.messages.at(-1) as { content: Anthropic.ToolResultBlockParam[] }; + const block = last.content.find((b) => b.tool_use_id === 'tu-journey-degrade'); + feedbackToModel = typeof block?.content === 'string' ? block.content : ''; + return Promise.resolve( + anthropicMsg([{ type: 'text', text: 'done', citations: [] }], 'end_turn'), + ); + }); + + const adapter = new FakeAdapter(); // no renderJourney + const brain = new SdkBrain({ + createMessage: createMsg, + callTool: (name, input) => + runtimeRef3.interceptCallTool(name, input, (n, i) => innerCallTool(n, i)), + tools: [], + model: 'm', + extendedReasoning: false, + }); + const store = new SessionStore(() => brain.newSession()); + const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp; + const runtime3 = new ConnectorRuntime({ adapter, brain, mcp, store }); + runtimeRef3 = runtime3; + await runtime3.start(); + + adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'journey' }); + await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'done'])); + + // The degrade note is brief and does NOT contain the raw timeline JSON. + expect(feedbackToModel).toContain('cannot render'); + expect(feedbackToModel).not.toContain('"timeline"'); + expect(feedbackToModel).not.toContain('"narrative"'); + }); + + it('(c) a non-render_session_journey tool result passes through unchanged', async () => { + // intercept a plain tool result for some_other_tool — must not be modified. + const plainResult = 'just a plain tool result string'; + const innerCallTool = vi.fn().mockResolvedValue(plainResult); + + let feedbackToModel = ''; + // biome-ignore lint/style/useConst: forward reference for a construction-time dependency cycle + let runtimeRef4: ConnectorRuntime; + const createMsg = vi + .fn() + .mockResolvedValueOnce( + anthropicMsg( + [ + { + type: 'tool_use', + id: 'tu-other-1', + name: 'list_recent_sessions', + input: {}, + caller: { type: 'direct' }, + } as Anthropic.ContentBlock, + ], + 'tool_use', + ), + ) + .mockImplementationOnce((req: Anthropic.MessageCreateParamsNonStreaming) => { + const last = req.messages.at(-1) as { content: Anthropic.ToolResultBlockParam[] }; + const block = last.content.find((b) => b.tool_use_id === 'tu-other-1'); + feedbackToModel = typeof block?.content === 'string' ? block.content : ''; + return Promise.resolve( + anthropicMsg([{ type: 'text', text: 'done', citations: [] }], 'end_turn'), + ); + }); + + const adapter = new JourneyAdapter(); // has renderJourney, but must NOT be called + const brain = new SdkBrain({ + createMessage: createMsg, + callTool: (name, input) => + runtimeRef4.interceptCallTool(name, input, (n, i) => innerCallTool(n, i)), + tools: [], + model: 'm', + extendedReasoning: false, + }); + const store = new SessionStore(() => brain.newSession()); + const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp; + const runtime4 = new ConnectorRuntime({ adapter, brain, mcp, store }); + runtimeRef4 = runtime4; + await runtime4.start(); + + adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'list sessions' }); + await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'done'])); + + // Passed through unchanged. + expect(feedbackToModel).toBe(plainResult); + // renderJourney was NOT called. + expect(adapter.renderJourneyCalls).toHaveLength(0); + }); + + it('(d) share_session interception still works alongside render_session_journey interception', async () => { + // Minimal regression: share_session path still triggers postFile. + const dir = await mkdtemp(join(tmpdir(), 'peek-test-')); + const bundlePath = join(dir, 'session.peekbundle'); + await writeFile(bundlePath, 'bundle-data'); + + const toolResult = JSON.stringify({ + ok: true, + bundlePath, + filename: 'session.peekbundle', + sizeBytes: 11, + caveat: '', + }); + const innerCallTool = vi.fn().mockResolvedValue(toolResult); + + class CombinedAdapter extends JourneyAdapter { + postFileCalls: Array<{ conversationId: string; filePath: string; filename: string }> = []; + async postFile(c: string, fp: string, fn: string, _comment?: string): Promise { + this.postFileCalls.push({ conversationId: c, filePath: fp, filename: fn }); + } + } + const adapter = new CombinedAdapter('canvas-link'); + const { runtime } = makeRealBrainRuntime({ adapter, innerCallTool }); + await runtime.start(); + + adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'share session' }); + await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'shared'])); + + // share_session still triggered postFile. + expect(adapter.postFileCalls).toHaveLength(1); + expect(adapter.postFileCalls[0]?.conversationId).toBe('c1'); + + // renderJourney was NOT called (wrong tool name). + expect(adapter.renderJourneyCalls).toHaveLength(0); + + // Temp file deleted. + await expect(rm(bundlePath, { force: false })).rejects.toThrow(); + await rm(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/connector-core/src/runtime.ts b/packages/connector-core/src/runtime.ts index da61e59..e3b6d7c 100644 --- a/packages/connector-core/src/runtime.ts +++ b/packages/connector-core/src/runtime.ts @@ -148,8 +148,13 @@ export class ConnectorRuntime { inner: (name: string, input: unknown) => Promise, ): Promise { const text = await inner(name, input); - if (name !== 'share_session') return text; - return this.#postProcessShareSession(this.#activeConversationId, text); + if (name === 'share_session') { + return this.#postProcessShareSession(this.#activeConversationId, text); + } + if (name === 'render_session_journey') { + return this.#postProcessRenderJourney(this.#activeConversationId, text); + } + return text; } /** @@ -203,6 +208,47 @@ export class ConnectorRuntime { } } + /** + * Given a `render_session_journey` tool-result string, hand the CausalChain to + * the surface adapter and return a short confirmation string to the brain. + * + * - Valid journey JSON (has `timeline` and `narrative`) AND `adapter.renderJourney` + * AND an active `conversationId` exist → `adapter.renderJourney(conversationId, journey)` + * is called; its return value (a short confirmation, e.g. a canvas link) is returned + * to the brain instead of the full CausalChain JSON — keeping the timeline out of + * LLM context. + * - `adapter.renderJourney` absent OR no active conversationId → degrade to a brief + * text note (no throw); the journey data is NOT leaked back to the brain. + * - Unparseable JSON or missing `timeline`/`narrative` fields → returned unchanged so + * the brain receives the raw text (e.g. "no console errors found" plain-text messages). + */ + async #postProcessRenderJourney( + conversationId: string | undefined, + text: string, + ): Promise { + const { adapter } = this.deps; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return text; // not valid JSON (e.g. "no console errors" plain text) — pass through + } + // Only intercept if it looks like a valid CausalChain (has timeline + narrative). + if ( + typeof parsed !== 'object' || + parsed === null || + !('timeline' in parsed) || + !('narrative' in parsed) + ) { + return text; + } + if (adapter.renderJourney && conversationId) { + return await adapter.renderJourney(conversationId, parsed); + } + // Degrade: no renderJourney support or no active conversation — return a brief note. + return 'Session journey is ready, but this surface cannot render it.'; + } + async start(): Promise { const { adapter, mcp, secretStore } = this.deps; // Load any previously-persisted pairing secret and arm it so every diff --git a/packages/connector-core/src/surface.ts b/packages/connector-core/src/surface.ts index 3801978..1b4f74c 100644 --- a/packages/connector-core/src/surface.ts +++ b/packages/connector-core/src/surface.ts @@ -40,4 +40,11 @@ export interface SurfaceAdapter { filename: string, comment?: string, ): Promise; + /** Optional: render a CausalChain journey to the surface (e.g. post a Slack canvas with + * the session timeline). Returns a short human-readable confirmation string for the brain + * (e.g. a canvas link or a brief note). Adapters that do not support journey rendering may + * omit this method; the runtime degrades to a brief text note so no crash occurs. + * The runtime calls this during render_session_journey interception — the full CausalChain + * JSON is never passed back to the brain to keep the timeline out of LLM context. */ + renderJourney?(conversationId: string, journey: unknown): Promise; } From 6834d99a3ee2b094a587229f173201f4364a5563 Mon Sep 17 00:00:00 2001 From: harry-harish <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:12:55 +0530 Subject: [PATCH 3/9] test(connector-core): fix dead assertion in (a), add plain-text + no-convId degrade tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: remove unused feedbackText variable and tautological assertion from test (a) of the render_session_journey suite — the deferred approach was replaced by (a-confirm); (a) now cleanly asserts only that adapter.renderJourney was called with (conversationId, journey). Fix 2: add test (e) — plain-text render_session_journey result (e.g. "no console errors found") passes through interceptCallTool unchanged, adapter.renderJourney NOT called, no throw. Driven through the real SdkBrain+interceptCallTool path. Fix 3: add test (f) — no active conversationId at interception time: interceptCallTool called directly (outside runLoop, #activeConversationId undefined) with a valid CausalChain JSON result → returns the brief degrade note, renderJourney NOT called, no throw. Production code (runtime.ts, surface.ts) byte-for-byte unchanged. 124 tests pass (was 122). Co-Authored-By: Claude Opus 4.8 Signed-off-by: harry-harish <22562634+harry-harish@users.noreply.github.com> --- packages/connector-core/src/runtime.test.ts | 103 +++++++++++++++++--- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/packages/connector-core/src/runtime.test.ts b/packages/connector-core/src/runtime.test.ts index 574ee7b..8533666 100644 --- a/packages/connector-core/src/runtime.test.ts +++ b/packages/connector-core/src/runtime.test.ts @@ -1118,12 +1118,6 @@ describe('render_session_journey interception — REAL SdkBrain inline (read) ro const { runtime } = makeJourneyBrainRuntime({ adapter, innerCallTool }); await runtime.start(); - // Capture what the brain receives as the tool_result so we can assert it is NOT the full JSON. - let feedbackText = ''; - // We need a custom createMessage to intercept the tool result fed back. - // Instead, use the adapter texts to verify the brain's final output is the confirmation. - // The brain's second createMessage turns end_turn 'journey rendered' — we check - // that the turn completes and renderJourney was called. adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'show session journey' }); await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'journey rendered'])); @@ -1147,12 +1141,6 @@ describe('render_session_journey interception — REAL SdkBrain inline (read) ro }); // No consent card posted (proves the read/inline path). expect(adapter.consents).toHaveLength(0); - - feedbackText = - adapter.renderJourneyCalls[0] !== undefined ? 'https://canvas.example.com/journey/42' : ''; - // The returned confirmation is NOT the raw timeline JSON. - expect(feedbackText).not.toContain('"timeline"'); - expect(feedbackText).toBe('https://canvas.example.com/journey/42'); }); it('(a-confirm) brain receives confirmation string, NOT the raw CausalChain JSON — verified via createMessage inspection', async () => { @@ -1367,4 +1355,95 @@ describe('render_session_journey interception — REAL SdkBrain inline (read) ro await expect(rm(bundlePath, { force: false })).rejects.toThrow(); await rm(dir, { recursive: true, force: true }); }); + + it('(e) plain-text result for render_session_journey passes through unchanged, renderJourney NOT called', async () => { + // peek-mcp returns plain text (e.g. "no console errors found") when there is + // nothing to render. interceptCallTool must return that text as-is — no JSON + // parse attempt, no renderJourney call, no throw. + const plainText = 'No console errors found for this session.'; + const innerCallTool = vi.fn().mockResolvedValue(plainText); + + let feedbackToModel = ''; + // biome-ignore lint/style/useConst: forward reference for a construction-time dependency cycle + let runtimeRef5: ConnectorRuntime; + const createMsg = vi + .fn() + .mockResolvedValueOnce( + anthropicMsg( + [ + { + type: 'tool_use', + id: 'tu-journey-plain', + name: 'render_session_journey', + input: { sessionId: 's1', errorId: 0 }, + caller: { type: 'direct' }, + } as Anthropic.ContentBlock, + ], + 'tool_use', + ), + ) + .mockImplementationOnce((req: Anthropic.MessageCreateParamsNonStreaming) => { + const last = req.messages.at(-1) as { content: Anthropic.ToolResultBlockParam[] }; + const block = last.content.find((b) => b.tool_use_id === 'tu-journey-plain'); + feedbackToModel = typeof block?.content === 'string' ? block.content : ''; + return Promise.resolve( + anthropicMsg([{ type: 'text', text: 'done', citations: [] }], 'end_turn'), + ); + }); + + const adapter = new JourneyAdapter(); // has renderJourney, but must NOT be called + const brain = new SdkBrain({ + createMessage: createMsg, + callTool: (name, input) => + runtimeRef5.interceptCallTool(name, input, (n, i) => innerCallTool(n, i)), + tools: [], + model: 'm', + extendedReasoning: false, + }); + const store = new SessionStore(() => brain.newSession()); + const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp; + const runtime5 = new ConnectorRuntime({ adapter, brain, mcp, store }); + runtimeRef5 = runtime5; + await runtime5.start(); + + adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'journey' }); + await vi.waitFor(() => expect(adapter.texts).toContainEqual(['c1', 'done'])); + + // Plain text passed through unchanged. + expect(feedbackToModel).toBe(plainText); + // renderJourney was NOT called. + expect(adapter.renderJourneyCalls).toHaveLength(0); + }); + + it('(f) no active conversationId at interception time → degrades to brief note, renderJourney NOT called, no throw', async () => { + // When interceptCallTool is invoked outside a runLoop turn (no active + // conversationId), a valid CausalChain JSON result must degrade gracefully: + // return the brief note, never call renderJourney, never throw. + const toolResult = JSON.stringify(CAUSAL_CHAIN_FIXTURE); + + const adapter = new JourneyAdapter('canvas-link'); + const store = new SessionStore(() => ({ history: [] })); + const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp; + const brain: Brain = { + newSession: (): Session => ({ history: [] }), + appendUserText: () => {}, + appendToolResult: () => {}, + runTurn: async () => ({ kind: 'done' as const, text: 'ok' }), + }; + const runtime = new ConnectorRuntime({ adapter, brain, mcp, store }); + // Do NOT start the runtime or fire any message — #activeConversationId stays undefined. + + // Call interceptCallTool directly: no active turn means no conversationId. + const result = await runtime.interceptCallTool( + 'render_session_journey', + { sessionId: 's1', errorId: 42 }, + async () => toolResult, + ); + + // Should return the degrade note, not the raw JSON. + expect(result).toContain('cannot render'); + expect(result).not.toContain('"timeline"'); + // renderJourney was NOT called. + expect(adapter.renderJourneyCalls).toHaveLength(0); + }); }); From 84202b731c42e005d7aa4dc3b405492adbac4786 Mon Sep 17 00:00:00 2001 From: harry-harish <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:22:39 +0530 Subject: [PATCH 4/9] =?UTF-8?q?feat(connector-slack):=20renderJourney=20?= =?UTF-8?q?=E2=80=94=20session-journey=20Slack=20canvas=20+=20Block=20Kit?= =?UTF-8?q?=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: harry-harish <22562634+harry-harish@users.noreply.github.com> --- packages/connector-slack/src/journey.test.ts | 293 ++++++++++++++++++ packages/connector-slack/src/journey.ts | 283 +++++++++++++++++ .../connector-slack/src/slack-adapter.test.ts | 161 ++++++++++ packages/connector-slack/src/slack-adapter.ts | 50 +++ 4 files changed, 787 insertions(+) create mode 100644 packages/connector-slack/src/journey.test.ts create mode 100644 packages/connector-slack/src/journey.ts diff --git a/packages/connector-slack/src/journey.test.ts b/packages/connector-slack/src/journey.test.ts new file mode 100644 index 0000000..5981d80 --- /dev/null +++ b/packages/connector-slack/src/journey.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest'; +import { + SLACK_BLOCK_LIMIT, + isJourneyCausalChain, + journeyBlocks, + journeyMarkdown, +} from './journey.js'; +import type { JourneyCausalChain, JourneyTimelineEntry } from './journey.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeEntry( + overrides: Partial & { kind: JourneyTimelineEntry['kind'] }, +): JourneyTimelineEntry { + return { + ts: 1000, + relMs: -500, + summary: `${overrides.kind} summary`, + ...overrides, + }; +} + +function makeJourney(overrides?: Partial): JourneyCausalChain { + return { + errorId: 1, + errorTs: 2000, + error: { + id: 1, + ts: 2000, + level: 'error', + message: 'TypeError: Cannot read property of undefined', + stack: 'Error: TypeError\n at foo (bar.js:10:5)', + }, + windowMs: 5000, + narrative: + 'In the 5000ms before console error #1: 2 user action(s), 1 network error(s), 0 DOM mutation(s).', + timeline: [ + makeEntry({ kind: 'action', relMs: -1500, summary: 'click `#submit`' }), + makeEntry({ kind: 'network', relMs: -800, summary: 'POST /api/save → 500' }), + makeEntry({ kind: 'error', relMs: 0, summary: 'console error: TypeError' }), + ], + networkErrors: [{ ts: 1200, method: 'POST', url: '/api/save', status: 500 }], + truncated: {}, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// journeyMarkdown tests +// --------------------------------------------------------------------------- + +describe('journeyMarkdown', () => { + it('contains the narrative text', () => { + const md = journeyMarkdown(makeJourney()); + expect(md).toContain('5000ms before console error'); + }); + + it('starts with an H1 failure headline containing the error level + message', () => { + const md = journeyMarkdown(makeJourney()); + const firstLine = md.split('\n')[0] ?? ''; + expect(firstLine).toMatch(/^# ERROR:/); + expect(firstLine).toContain('TypeError'); + }); + + it('contains an H2 "The path" section', () => { + const md = journeyMarkdown(makeJourney()); + expect(md).toContain('## The path'); + }); + + it('renders each timeline entry with its relMs offset and summary', () => { + const md = journeyMarkdown(makeJourney()); + expect(md).toContain('`-1500ms`'); + expect(md).toContain('click `#submit`'); + expect(md).toContain('`-800ms`'); + expect(md).toContain('POST /api/save'); + expect(md).toContain('`+0ms`'); + }); + + it('renders per-kind emoji: 🖱 for action, 🌐 for network, ⚠ for error', () => { + const md = journeyMarkdown(makeJourney()); + expect(md).toContain('🖱'); + expect(md).toContain('🌐'); + expect(md).toContain('⚠'); + }); + + it('renders ⌨ emoji for action summaries starting with "type "', () => { + const journey = makeJourney({ + timeline: [ + makeEntry({ kind: 'action', relMs: -100, summary: 'type "hello" into #name' }), + makeEntry({ kind: 'error', relMs: 0, summary: 'console error: oops' }), + ], + }); + const md = journeyMarkdown(journey); + expect(md).toContain('⌨'); + }); + + it('renders 🧭 emoji for action summaries starting with "navigate "', () => { + const journey = makeJourney({ + timeline: [ + makeEntry({ kind: 'action', relMs: -100, summary: 'navigate to https://example.com' }), + makeEntry({ kind: 'error', relMs: 0, summary: 'console error: oops' }), + ], + }); + const md = journeyMarkdown(journey); + expect(md).toContain('🧭'); + }); + + it('contains an H2 "Network failures" table when networkErrors is non-empty', () => { + const md = journeyMarkdown(makeJourney()); + expect(md).toContain('## Network failures'); + expect(md).toContain('| Method | URL | Status |'); + expect(md).toContain('| POST | /api/save | 500 |'); + }); + + it('omits the Network failures section when networkErrors is empty', () => { + const md = journeyMarkdown(makeJourney({ networkErrors: [] })); + expect(md).not.toContain('## Network failures'); + }); + + it('renders the stack trace code block when error.stack is present', () => { + const md = journeyMarkdown(makeJourney()); + expect(md).toContain('## Stack trace'); + expect(md).toContain('```'); + expect(md).toContain('at foo (bar.js'); + }); + + it('omits the stack trace section when error.stack is absent', () => { + const journey = makeJourney(); + const journeyNoStack: JourneyCausalChain = { + ...journey, + error: { + id: journey.error.id, + ts: journey.error.ts, + level: journey.error.level, + message: journey.error.message, + }, + }; + const md = journeyMarkdown(journeyNoStack); + expect(md).not.toContain('## Stack trace'); + }); + + it('truncates long timelines with a "+N more" line', () => { + const manyEntries: JourneyTimelineEntry[] = Array.from({ length: 250 }, (_, i) => ({ + ts: 1000 + i, + relMs: i - 200, + kind: 'action' as const, + summary: `click #btn-${i}`, + })); + const journey = makeJourney({ timeline: manyEntries }); + const md = journeyMarkdown(journey); + expect(md).toContain('more entries (truncated)'); + // Should NOT contain all 250 entries + expect(md).not.toContain('#btn-249'); + }); + + it('does not truncate timelines within MAX_TIMELINE_ENTRIES (200)', () => { + const entries: JourneyTimelineEntry[] = Array.from({ length: 150 }, (_, i) => ({ + ts: 1000 + i, + relMs: i - 150, + kind: 'action' as const, + summary: `click #btn-${i}`, + })); + const journey = makeJourney({ timeline: entries }); + const md = journeyMarkdown(journey); + expect(md).not.toContain('more entries'); + expect(md).toContain('#btn-149'); + }); + + it('caps network error table at MAX_TABLE_ROWS (25) with a "+N more" row', () => { + const manyNet = Array.from({ length: 40 }, (_, i) => ({ + ts: 1000 + i, + method: 'GET', + url: `/api/resource/${i}`, + status: 404, + })); + const journey = makeJourney({ networkErrors: manyNet }); + const md = journeyMarkdown(journey); + expect(md).toContain('_+15 more_'); + }); +}); + +// --------------------------------------------------------------------------- +// journeyBlocks tests +// --------------------------------------------------------------------------- + +describe('journeyBlocks', () => { + it('returns an array of blocks with a header and narrative section', () => { + const blocks = journeyBlocks(makeJourney()); + const header = blocks.find((b) => b.type === 'header') as + | { text: { text: string } } + | undefined; + expect(header).toBeDefined(); + expect(header?.text.text).toContain('ERROR'); + expect(header?.text.text).toContain('TypeError'); + + const section = blocks.find((b) => b.type === 'section') as + | { text: { text: string } } + | undefined; + expect(section?.text.text).toContain('5000ms'); + }); + + it('includes a "The path" label block', () => { + const blocks = journeyBlocks(makeJourney()); + const pathLabel = blocks + .filter((b) => b.type === 'section') + .find((b) => (b as { text: { text: string } }).text.text === '*The path*'); + expect(pathLabel).toBeDefined(); + }); + + it('renders timeline entries as section blocks', () => { + const blocks = journeyBlocks(makeJourney()); + const texts = blocks + .filter((b) => b.type === 'section') + .map((b) => (b as { text: { text: string } }).text.text); + const hasBtnEntry = texts.some((t) => t.includes('click `#submit`')); + expect(hasBtnEntry).toBe(true); + }); + + it('never exceeds SLACK_BLOCK_LIMIT (50) blocks', () => { + const manyEntries: JourneyTimelineEntry[] = Array.from({ length: 100 }, (_, i) => ({ + ts: 1000 + i, + relMs: i - 100, + kind: 'action' as const, + summary: `click #btn-${i}`, + })); + const journey = makeJourney({ timeline: manyEntries }); + const blocks = journeyBlocks(journey); + expect(blocks.length).toBeLessThanOrEqual(SLACK_BLOCK_LIMIT); + }); + + it('appends a "+N more" context block when timeline is truncated', () => { + const manyEntries: JourneyTimelineEntry[] = Array.from({ length: 50 }, (_, i) => ({ + ts: 1000 + i, + relMs: i - 50, + kind: 'action' as const, + summary: `click #btn-${i}`, + })); + const journey = makeJourney({ timeline: manyEntries }); + const blocks = journeyBlocks(journey); + const contextBlocks = blocks.filter((b) => b.type === 'context') as Array<{ + elements: Array<{ text: string }>; + }>; + const hasTruncation = contextBlocks.some((c) => + c.elements.some((e) => e.text.includes('more timeline entries')), + ); + expect(hasTruncation).toBe(true); + }); + + it('does not add a truncation block when timeline fits', () => { + const blocks = journeyBlocks(makeJourney()); + const contextBlocks = blocks.filter((b) => b.type === 'context') as Array<{ + elements: Array<{ text: string }>; + }>; + const hasTruncation = contextBlocks.some((c) => + c.elements.some((e) => e.text.includes('more timeline entries')), + ); + expect(hasTruncation).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isJourneyCausalChain type guard +// --------------------------------------------------------------------------- + +describe('isJourneyCausalChain', () => { + it('returns true for a well-formed CausalChain object', () => { + expect(isJourneyCausalChain(makeJourney())).toBe(true); + }); + + it('returns false for null', () => { + expect(isJourneyCausalChain(null)).toBe(false); + }); + + it('returns false for a plain string', () => { + expect(isJourneyCausalChain('not a chain')).toBe(false); + }); + + it('returns false when errorId is missing', () => { + const { errorId: _removed, ...rest } = makeJourney(); + expect(isJourneyCausalChain(rest)).toBe(false); + }); + + it('returns false when narrative is missing', () => { + const { narrative: _removed, ...rest } = makeJourney(); + expect(isJourneyCausalChain(rest)).toBe(false); + }); + + it('returns false when timeline is not an array', () => { + expect(isJourneyCausalChain({ ...makeJourney(), timeline: 'not-array' })).toBe(false); + }); +}); diff --git a/packages/connector-slack/src/journey.ts b/packages/connector-slack/src/journey.ts new file mode 100644 index 0000000..a8f2200 --- /dev/null +++ b/packages/connector-slack/src/journey.ts @@ -0,0 +1,283 @@ +/** + * journey.ts — CausalChain → Slack canvas markdown + Block Kit fallback. + * + * Types are defined locally so this package does not take a runtime dependency + * on @peekdev/mcp. The shape mirrors `CausalChain` / `TimelineEntry` from + * packages/peek-mcp/src/mcp/causal-chain.ts exactly — kept in sync manually. + */ +import type { KnownBlock } from '@slack/types'; + +// --------------------------------------------------------------------------- +// Local CausalChain shape (mirrors peek-mcp; no runtime dep on that package) +// --------------------------------------------------------------------------- + +export interface JourneyTimelineEntry { + readonly ts: number; + readonly relMs: number; + readonly kind: 'action' | 'dom' | 'network' | 'error'; + readonly summary: string; + readonly ref?: number; +} + +export interface JourneyError { + readonly id: number; + readonly ts: number; + readonly level: string; + readonly message: string; + readonly stack?: string; +} + +export interface JourneyNetworkError { + readonly ts: number; + readonly method: string; + readonly url: string; + readonly status?: number; + readonly errorText?: string; +} + +export interface JourneyCausalChain { + readonly errorId: number; + readonly errorTs: number; + readonly error: JourneyError; + readonly windowMs: number; + readonly timeline: JourneyTimelineEntry[]; + readonly narrative: string; + readonly networkErrors: JourneyNetworkError[]; + readonly truncated: { readonly domMutations?: boolean; readonly networkErrors?: boolean }; +} + +// --------------------------------------------------------------------------- +// Canvas markdown limits +// --------------------------------------------------------------------------- + +/** Maximum timeline entries to render inline before we truncate. Keeps the + * canvas markdown well under Slack's `canvas_too_large` limit (≈1 MB). */ +const MAX_TIMELINE_ENTRIES = 200; + +/** Maximum rows per table (header + rows); Slack canvas hard-caps at 300 cells. */ +const MAX_TABLE_ROWS = 25; // 25 rows × 3 cols = 75 cells per table, leaves plenty of margin + +/** Slack Block Kit hard limit on the number of blocks in a message. */ +export const SLACK_BLOCK_LIMIT = 50; + +// --------------------------------------------------------------------------- +// Per-kind emoji +// --------------------------------------------------------------------------- + +function kindEmoji(kind: JourneyTimelineEntry['kind'], summary: string): string { + switch (kind) { + case 'error': + return '⚠'; + case 'network': + return '🌐'; + case 'dom': + return '🔲'; + case 'action': { + // Best-effort input detection from summary text (masking hides value) + const lower = summary.toLowerCase(); + if (lower.startsWith('type ') || lower.startsWith('fill ')) return '⌨'; + if (lower.startsWith('navigate ') || lower.startsWith('go to ')) return '🧭'; + return '🖱'; + } + default: + return '•'; + } +} + +// --------------------------------------------------------------------------- +// journeyMarkdown — pure CausalChain → canvas markdown string +// --------------------------------------------------------------------------- + +/** Format a relMs offset as a compact signed string (e.g. "-1500ms", "+0ms"). */ +function fmtRel(relMs: number): string { + const sign = relMs >= 0 ? '+' : ''; + return `${sign}${relMs}ms`; +} + +/** Cap a string to at most `max` chars with a trailing "…" marker. */ +function cap(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max)}…`; +} + +/** + * Render a `CausalChain` to a Slack canvas markdown string. + * + * Layout: + * - H1: failure headline + * - narrative paragraph + * - H2 "The path": ordered list of timeline entries (with emoji + relMs) + * - H2 "Network failures": markdown table (capped under 300 cells) + * - Code block: stack trace (if present) + * + * Long timelines are truncated at MAX_TIMELINE_ENTRIES with a "+N more" line. + * Pure function — no side effects, fully unit-testable. + */ +export function journeyMarkdown(journey: JourneyCausalChain): string { + const { error, narrative, timeline, networkErrors } = journey; + + const lines: string[] = []; + + // H1 — failure headline + const headline = cap(error.message, 120); + lines.push(`# ${error.level.toUpperCase()}: ${headline}`); + lines.push(''); + + // Narrative paragraph + lines.push(narrative); + lines.push(''); + + // H2 "The path" — ordered timeline list + lines.push('## The path'); + lines.push(''); + + const visibleEntries = + timeline.length <= MAX_TIMELINE_ENTRIES ? timeline : timeline.slice(0, MAX_TIMELINE_ENTRIES); + const hiddenCount = timeline.length - visibleEntries.length; + + let i = 1; + for (const entry of visibleEntries) { + const emoji = kindEmoji(entry.kind, entry.summary); + const rel = fmtRel(entry.relMs); + lines.push(`${i}. ${emoji} \`${rel}\` ${cap(entry.summary, 200)}`); + i++; + } + + if (hiddenCount > 0) { + lines.push(`${i}. … _+${hiddenCount} more entries (truncated)_`); + } + + lines.push(''); + + // H2 "Network failures" — markdown table (only if there are any) + if (networkErrors.length > 0) { + lines.push('## Network failures'); + lines.push(''); + lines.push('| Method | URL | Status |'); + lines.push('| --- | --- | --- |'); + + const visibleNet = + networkErrors.length <= MAX_TABLE_ROWS + ? networkErrors + : networkErrors.slice(0, MAX_TABLE_ROWS); + const hiddenNet = networkErrors.length - visibleNet.length; + + for (const n of visibleNet) { + const status = n.status !== undefined ? String(n.status) : (n.errorText ?? 'error'); + const url = cap(n.url, 80); + lines.push(`| ${n.method} | ${url} | ${status} |`); + } + + if (hiddenNet > 0) { + lines.push(`| … | _+${hiddenNet} more_ | |`); + } + + lines.push(''); + } + + // Code block — stack trace (if present) + if (error.stack) { + lines.push('## Stack trace'); + lines.push(''); + // Cap the stack to avoid canvas_too_large; 4000 chars is generous headroom + const stack = cap(error.stack, 4000); + lines.push('```'); + lines.push(stack); + lines.push('```'); + lines.push(''); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// journeyBlocks — Block Kit fallback (≤ SLACK_BLOCK_LIMIT blocks) +// --------------------------------------------------------------------------- + +/** Maximum timeline entries in the Block Kit fallback message. + * 3 blocks per entry (section + divider + breathing room) × ~15 = ~45 blocks; + * leaves room for the header + narrative + footer blocks. */ +const MAX_BK_TIMELINE_ENTRIES = 12; + +/** + * Render a `CausalChain` to a Slack Block Kit message (array of KnownBlock[]). + * + * Used as the fallback when `canvases.create` is unavailable (free teams, + * `canvas_disabled_user_team`, etc.). Truncates the timeline at + * MAX_BK_TIMELINE_ENTRIES with a "+N more" context block, and caps the + * total block count at SLACK_BLOCK_LIMIT. + * + * Pure function — no side effects. + */ +export function journeyBlocks(journey: JourneyCausalChain): KnownBlock[] { + const { error, narrative, timeline } = journey; + + const blocks: KnownBlock[] = []; + + // Header + const headline = cap(error.message, 150); + blocks.push({ + type: 'header', + text: { type: 'plain_text', text: `${error.level.toUpperCase()}: ${headline}` }, + }); + + // Narrative section + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: cap(narrative, 2900) }, + }); + + // Divider before timeline + blocks.push({ type: 'divider' }); + + // "The path" label + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: '*The path*' }, + }); + + const visibleEntries = + timeline.length <= MAX_BK_TIMELINE_ENTRIES + ? timeline + : timeline.slice(0, MAX_BK_TIMELINE_ENTRIES); + const hiddenCount = timeline.length - visibleEntries.length; + + for (const entry of visibleEntries) { + const emoji = kindEmoji(entry.kind, entry.summary); + const rel = fmtRel(entry.relMs); + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `${emoji} \`${rel}\` ${cap(entry.summary, 200)}` }, + }); + } + + if (hiddenCount > 0) { + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `_+${hiddenCount} more timeline entries (use canvas for the full journey)_`, + }, + ], + }); + } + + // Cap at SLACK_BLOCK_LIMIT + return blocks.slice(0, SLACK_BLOCK_LIMIT); +} + +// --------------------------------------------------------------------------- +// Type guard — validate that an `unknown` value looks like a CausalChain +// --------------------------------------------------------------------------- + +export function isJourneyCausalChain(v: unknown): v is JourneyCausalChain { + if (typeof v !== 'object' || v === null) return false; + const o = v as Record; + return ( + typeof o.errorId === 'number' && + typeof o.narrative === 'string' && + Array.isArray(o.timeline) && + typeof o.error === 'object' && + o.error !== null + ); +} diff --git a/packages/connector-slack/src/slack-adapter.test.ts b/packages/connector-slack/src/slack-adapter.test.ts index be9a9e7..9f58a5d 100644 --- a/packages/connector-slack/src/slack-adapter.test.ts +++ b/packages/connector-slack/src/slack-adapter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { JourneyCausalChain } from './journey.js'; import { SlackAdapter, stripMention } from './slack-adapter.js'; import { parseConsentValue, suggestedPrompts } from './slack-adapter.js'; @@ -506,3 +507,163 @@ describe('SlackAdapter.postFile', () => { ); }); }); + +// --------------------------------------------------------------------------- +// SlackAdapter.renderJourney +// --------------------------------------------------------------------------- + +/** Minimal CausalChain fixture for renderJourney tests. */ +const sampleJourney: JourneyCausalChain = { + errorId: 1, + errorTs: 2000, + error: { + id: 1, + ts: 2000, + level: 'error', + message: 'TypeError: Cannot read properties of undefined', + stack: 'Error: TypeError\n at foo (bar.js:5:10)', + }, + windowMs: 5000, + narrative: + 'In the 5000ms before console error #1: 1 user action(s), 1 network error(s), 0 DOM mutation(s).', + timeline: [ + { ts: 1000, relMs: -1000, kind: 'action', summary: 'click `#submit`' }, + { ts: 1500, relMs: -500, kind: 'network', summary: 'POST /api/save → 500' }, + { ts: 2000, relMs: 0, kind: 'error', summary: 'console error: TypeError' }, + ], + networkErrors: [{ ts: 1500, method: 'POST', url: '/api/save', status: 500 }], + truncated: {}, +}; + +/** Build an adapter with a full client mock including canvases.create. */ +function makeAdapterWithCanvas(canvasResult?: { canvas_id?: string }): { + adapter: SlackAdapter; + postMessage: ReturnType; + canvasCreate: ReturnType; +} { + const postMessage = vi.fn().mockResolvedValue({}); + const canvasCreate = vi.fn().mockResolvedValue(canvasResult ?? { canvas_id: 'Fcanvas123' }); + const adapter = new SlackAdapter({ + slackBotToken: 'xoxb-test', + slackAppToken: 'xapp-test', + } as never); + (adapter as unknown as { app: { client: unknown } }).app.client = { + chat: { postMessage }, + assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, + canvases: { create: canvasCreate }, + }; + return { adapter, postMessage, canvasCreate }; +} + +describe('SlackAdapter.renderJourney — canvas path', () => { + it('calls canvases.create with title + document_content markdown, then posts a confirmation', async () => { + const { adapter, postMessage, canvasCreate } = makeAdapterWithCanvas({ + canvas_id: 'Fcanvas001', + }); + ( + adapter as unknown as { routes: Map } + ).routes.set('t-canvas', { channel: 'C10', threadTs: 'T10' }); + + const result = await adapter.renderJourney('t-canvas', sampleJourney); + + // canvases.create was called with a title + document_content + expect(canvasCreate).toHaveBeenCalledTimes(1); + const createArg = canvasCreate.mock.calls[0]?.[0] as Record; + expect(typeof createArg.title).toBe('string'); + const content = createArg.document_content as { type: string; markdown: string }; + expect(content.type).toBe('markdown'); + expect(typeof content.markdown).toBe('string'); + // Markdown must contain the narrative and timeline entry + expect(content.markdown).toContain('5000ms before console error'); + expect(content.markdown).toContain('click `#submit`'); + + // chat.postMessage was called to post the confirmation into the thread + expect(postMessage).toHaveBeenCalledTimes(1); + const msgArg = postMessage.mock.calls[0]?.[0] as Record; + expect(msgArg.channel).toBe('C10'); + expect(msgArg.thread_ts).toBe('T10'); + + // Returned confirmation string references the canvas id + expect(result).toContain('Fcanvas001'); + }); + + it('omits thread_ts from chat.postMessage when the route has no threadTs (slash path)', async () => { + const { adapter, postMessage } = makeAdapterWithCanvas({ canvas_id: 'Fcanvas002' }); + ( + adapter as unknown as { routes: Map } + ).routes.set('cmd-canvas', { channel: 'C20' }); + + await adapter.renderJourney('cmd-canvas', sampleJourney); + + const msgArg = postMessage.mock.calls[0]?.[0] as Record; + expect(msgArg.channel).toBe('C20'); + expect('thread_ts' in msgArg).toBe(false); + }); +}); + +describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { + it('falls back to journeyBlocks + chat.postMessage when canvases.create rejects', async () => { + const postMessage = vi.fn().mockResolvedValue({}); + const canvasCreate = vi.fn().mockRejectedValue(new Error('canvas_disabled_user_team')); + const adapter = new SlackAdapter({ + slackBotToken: 'xoxb-test', + slackAppToken: 'xapp-test', + } as never); + (adapter as unknown as { app: { client: unknown } }).app.client = { + chat: { postMessage }, + assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, + canvases: { create: canvasCreate }, + }; + ( + adapter as unknown as { routes: Map } + ).routes.set('t-fallback', { channel: 'C30', threadTs: 'T30' }); + + const result = await adapter.renderJourney('t-fallback', sampleJourney); + + // canvases.create was attempted + expect(canvasCreate).toHaveBeenCalledTimes(1); + // chat.postMessage was called with fallback blocks + expect(postMessage).toHaveBeenCalledTimes(1); + const msgArg = postMessage.mock.calls[0]?.[0] as Record; + expect(msgArg.channel).toBe('C30'); + expect(msgArg.thread_ts).toBe('T30'); + // blocks must be an array (the fallback journeyBlocks output) + expect(Array.isArray(msgArg.blocks)).toBe(true); + const blocks = msgArg.blocks as Array<{ type: string }>; + expect(blocks.length).toBeGreaterThan(0); + expect(blocks.length).toBeLessThanOrEqual(50); + + // Confirmation string indicates fallback was used + expect(result).toContain('canvas unavailable'); + }); + + it('does not throw even when both canvases.create and chat.postMessage succeed on fallback', async () => { + const postMessage = vi.fn().mockResolvedValue({}); + const canvasCreate = vi.fn().mockRejectedValue(new Error('not_allowed')); + const adapter = new SlackAdapter({ + slackBotToken: 'xoxb-test', + slackAppToken: 'xapp-test', + } as never); + (adapter as unknown as { app: { client: unknown } }).app.client = { + chat: { postMessage }, + assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, + canvases: { create: canvasCreate }, + }; + ( + adapter as unknown as { routes: Map } + ).routes.set('t-no-throw', { channel: 'C40', threadTs: 'T40' }); + + await expect(adapter.renderJourney('t-no-throw', sampleJourney)).resolves.not.toThrow(); + }); + + it('throws when journey is not a valid CausalChain', async () => { + const { adapter } = makeAdapterWithCanvas(); + ( + adapter as unknown as { routes: Map } + ).routes.set('t-bad', { channel: 'C50', threadTs: 'T50' }); + + await expect(adapter.renderJourney('t-bad', { not: 'a chain' })).rejects.toThrow( + 'not a valid CausalChain', + ); + }); +}); diff --git a/packages/connector-slack/src/slack-adapter.ts b/packages/connector-slack/src/slack-adapter.ts index 8655d16..44db6d5 100644 --- a/packages/connector-slack/src/slack-adapter.ts +++ b/packages/connector-slack/src/slack-adapter.ts @@ -9,6 +9,7 @@ import { App, Assistant } from '@slack/bolt'; import type { BlockAction } from '@slack/bolt'; import { confirmation, consentCard, errorBlock, resultBlocks } from './blockkit.js'; import type { SlackConfig } from './config.js'; +import { isJourneyCausalChain, journeyBlocks, journeyMarkdown } from './journey.js'; interface Route { channel: string; @@ -184,6 +185,55 @@ export class SlackAdapter implements SurfaceAdapter { } } + async renderJourney(conversationId: string, journey: unknown): Promise { + const r = this.route(conversationId); + if (!isJourneyCausalChain(journey)) { + throw new Error('renderJourney: journey is not a valid CausalChain'); + } + + const markdown = journeyMarkdown(journey); + const title = `Session journey — ${journey.error.level.toUpperCase()}: ${journey.error.message.slice(0, 60)}`; + + // Primary path: create a Slack canvas. + // canvases.create returns { canvas_id } (no URL field in the API). + // On any canvas error (free team, canvas_disabled_user_team, etc.) fall back to Block Kit. + let canvasId: string | undefined; + try { + const result = await this.app.client.canvases.create({ + title, + document_content: { type: 'markdown', markdown }, + }); + canvasId = result.canvas_id; + } catch { + // Canvas unavailable — fall through to Block Kit fallback + } + + if (canvasId !== undefined) { + // Post a confirmation message referencing the canvas id into the thread. + // Slack renders canvas references when the canvas_id is mentioned. + const text = `🗺 Session journey canvas created. Canvas ID: \`${canvasId}\``; + await this.app.client.chat.postMessage({ + channel: r.channel, + ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), + // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here + blocks: confirmation(text) as any, + text, + }); + return text; + } + + // Fallback: Block Kit timeline summary posted directly to the thread. + const fallbackBlocks = journeyBlocks(journey); + await this.app.client.chat.postMessage({ + channel: r.channel, + ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), + // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here + blocks: fallbackBlocks as any, + text: 'Session journey (canvas unavailable — showing summary)', + }); + return 'Session journey posted as a message (Slack canvas unavailable on this workspace).'; + } + private emit( conversationId: string, channel: string, From 659278d903fa3efe8b5a7699e91a3296eecb5ba1 Mon Sep 17 00:00:00 2001 From: Harish Kumar <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:38:37 +0530 Subject: [PATCH 5/9] fix(connector-slack): channel-linked canvas + permalink link in renderJourney MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace canvases.create with conversations.canvases.create({ channel_id, title, document_content }) so the canvas is channel-linked and visible to members - Fetch clickable permalink via files.info({ file: canvas_id }) after canvas creation - Post mrkdwn link instead of bare canvas ID - Add console.warn for canvas unavailable + files.info failure (observable errors) - Fix isJourneyCausalChain to check typeof error.message === 'string' - Fix misleading MAX_BK_TIMELINE_ENTRIES comment (1 block/entry × 12 + ~4 fixed) - Update tests: renamed helper to conversationsCanvasesCreate + filesInfo mock, assert channel_id arg + files.info call + mrkdwn link in text; new test for canvas_id:undefined → Block Kit fallback; fallback tests use new mock structure Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Harish Kumar <22562634+harry-harish@users.noreply.github.com> --- packages/connector-slack/src/journey.ts | 22 +++-- .../connector-slack/src/slack-adapter.test.ts | 84 ++++++++++++++----- packages/connector-slack/src/slack-adapter.ts | 28 +++++-- 3 files changed, 96 insertions(+), 38 deletions(-) diff --git a/packages/connector-slack/src/journey.ts b/packages/connector-slack/src/journey.ts index a8f2200..7af7609 100644 --- a/packages/connector-slack/src/journey.ts +++ b/packages/connector-slack/src/journey.ts @@ -194,8 +194,9 @@ export function journeyMarkdown(journey: JourneyCausalChain): string { // --------------------------------------------------------------------------- /** Maximum timeline entries in the Block Kit fallback message. - * 3 blocks per entry (section + divider + breathing room) × ~15 = ~45 blocks; - * leaves room for the header + narrative + footer blocks. */ + * 1 section block per entry × 12 entries = 12 entry blocks; + * plus ~4 fixed blocks (header + narrative + divider + "The path" label) = ~16 total, + * well within SLACK_BLOCK_LIMIT (50). */ const MAX_BK_TIMELINE_ENTRIES = 12; /** @@ -273,11 +274,14 @@ export function journeyBlocks(journey: JourneyCausalChain): KnownBlock[] { export function isJourneyCausalChain(v: unknown): v is JourneyCausalChain { if (typeof v !== 'object' || v === null) return false; const o = v as Record; - return ( - typeof o.errorId === 'number' && - typeof o.narrative === 'string' && - Array.isArray(o.timeline) && - typeof o.error === 'object' && - o.error !== null - ); + if ( + typeof o.errorId !== 'number' || + typeof o.narrative !== 'string' || + !Array.isArray(o.timeline) + ) + return false; + const err = o.error; + if (typeof err !== 'object' || err === null) return false; + const e = err as Record; + return typeof e.message === 'string'; } diff --git a/packages/connector-slack/src/slack-adapter.test.ts b/packages/connector-slack/src/slack-adapter.test.ts index 9f58a5d..e00619b 100644 --- a/packages/connector-slack/src/slack-adapter.test.ts +++ b/packages/connector-slack/src/slack-adapter.test.ts @@ -535,14 +535,20 @@ const sampleJourney: JourneyCausalChain = { truncated: {}, }; -/** Build an adapter with a full client mock including canvases.create. */ +/** Build an adapter with a full client mock including conversations.canvases.create + files.info. */ function makeAdapterWithCanvas(canvasResult?: { canvas_id?: string }): { adapter: SlackAdapter; postMessage: ReturnType; - canvasCreate: ReturnType; + conversationsCanvasesCreate: ReturnType; + filesInfo: ReturnType; } { const postMessage = vi.fn().mockResolvedValue({}); - const canvasCreate = vi.fn().mockResolvedValue(canvasResult ?? { canvas_id: 'Fcanvas123' }); + const conversationsCanvasesCreate = vi + .fn() + .mockResolvedValue(canvasResult ?? { canvas_id: 'Fcanvas123' }); + const filesInfo = vi + .fn() + .mockResolvedValue({ file: { permalink: 'https://slack.com/canvas/Fcanvas001' } }); const adapter = new SlackAdapter({ slackBotToken: 'xoxb-test', slackAppToken: 'xapp-test', @@ -550,25 +556,31 @@ function makeAdapterWithCanvas(canvasResult?: { canvas_id?: string }): { (adapter as unknown as { app: { client: unknown } }).app.client = { chat: { postMessage }, assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, - canvases: { create: canvasCreate }, + conversations: { canvases: { create: conversationsCanvasesCreate } }, + files: { info: filesInfo, uploadV2: vi.fn().mockResolvedValue({}) }, }; - return { adapter, postMessage, canvasCreate }; + return { adapter, postMessage, conversationsCanvasesCreate, filesInfo }; } describe('SlackAdapter.renderJourney — canvas path', () => { - it('calls canvases.create with title + document_content markdown, then posts a confirmation', async () => { - const { adapter, postMessage, canvasCreate } = makeAdapterWithCanvas({ + it('calls conversations.canvases.create with channel_id + title + document_content, fetches permalink via files.info, and posts a clickable mrkdwn link', async () => { + const { adapter, postMessage, conversationsCanvasesCreate, filesInfo } = makeAdapterWithCanvas({ canvas_id: 'Fcanvas001', }); + // Override filesInfo to return a specific permalink for this test + filesInfo.mockResolvedValue({ + file: { permalink: 'https://slack.com/canvas/Fcanvas001' }, + }); ( adapter as unknown as { routes: Map } ).routes.set('t-canvas', { channel: 'C10', threadTs: 'T10' }); const result = await adapter.renderJourney('t-canvas', sampleJourney); - // canvases.create was called with a title + document_content - expect(canvasCreate).toHaveBeenCalledTimes(1); - const createArg = canvasCreate.mock.calls[0]?.[0] as Record; + // conversations.canvases.create was called with channel_id, title, and document_content + expect(conversationsCanvasesCreate).toHaveBeenCalledTimes(1); + const createArg = conversationsCanvasesCreate.mock.calls[0]?.[0] as Record; + expect(createArg.channel_id).toBe('C10'); expect(typeof createArg.title).toBe('string'); const content = createArg.document_content as { type: string; markdown: string }; expect(content.type).toBe('markdown'); @@ -577,14 +589,21 @@ describe('SlackAdapter.renderJourney — canvas path', () => { expect(content.markdown).toContain('5000ms before console error'); expect(content.markdown).toContain('click `#submit`'); + // files.info was called with the canvas_id to resolve the permalink + expect(filesInfo).toHaveBeenCalledTimes(1); + expect(filesInfo).toHaveBeenCalledWith({ file: 'Fcanvas001' }); + // chat.postMessage was called to post the confirmation into the thread expect(postMessage).toHaveBeenCalledTimes(1); const msgArg = postMessage.mock.calls[0]?.[0] as Record; expect(msgArg.channel).toBe('C10'); expect(msgArg.thread_ts).toBe('T10'); + // text must contain the mrkdwn clickable link, not a bare canvas ID + const msgText = msgArg.text as string; + expect(msgText).toContain(''); - // Returned confirmation string references the canvas id - expect(result).toContain('Fcanvas001'); + // Returned value contains the permalink URL + expect(result).toContain('https://slack.com/canvas/Fcanvas001'); }); it('omits thread_ts from chat.postMessage when the route has no threadTs (slash path)', async () => { @@ -599,12 +618,33 @@ describe('SlackAdapter.renderJourney — canvas path', () => { expect(msgArg.channel).toBe('C20'); expect('thread_ts' in msgArg).toBe(false); }); + + it('falls back to Block Kit when conversations.canvases.create returns canvas_id: undefined', async () => { + const { adapter, postMessage, conversationsCanvasesCreate } = makeAdapterWithCanvas({}); + ( + adapter as unknown as { routes: Map } + ).routes.set('t-no-canvas-id', { channel: 'C50', threadTs: 'T50' }); + + const result = await adapter.renderJourney('t-no-canvas-id', sampleJourney); + + // conversations.canvases.create was attempted + expect(conversationsCanvasesCreate).toHaveBeenCalledTimes(1); + // canvas_id was undefined → fall through to Block Kit fallback + expect(postMessage).toHaveBeenCalledTimes(1); + const msgArg = postMessage.mock.calls[0]?.[0] as Record; + // blocks array (not a canvas link) was posted + expect(Array.isArray(msgArg.blocks)).toBe(true); + // Confirmation string indicates fallback was used + expect(result).toContain('canvas unavailable'); + }); }); describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { - it('falls back to journeyBlocks + chat.postMessage when canvases.create rejects', async () => { + it('falls back to journeyBlocks + chat.postMessage when conversations.canvases.create rejects', async () => { const postMessage = vi.fn().mockResolvedValue({}); - const canvasCreate = vi.fn().mockRejectedValue(new Error('canvas_disabled_user_team')); + const conversationsCanvasesCreate = vi + .fn() + .mockRejectedValue(new Error('canvas_disabled_user_team')); const adapter = new SlackAdapter({ slackBotToken: 'xoxb-test', slackAppToken: 'xapp-test', @@ -612,7 +652,8 @@ describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { (adapter as unknown as { app: { client: unknown } }).app.client = { chat: { postMessage }, assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, - canvases: { create: canvasCreate }, + conversations: { canvases: { create: conversationsCanvasesCreate } }, + files: { info: vi.fn(), uploadV2: vi.fn().mockResolvedValue({}) }, }; ( adapter as unknown as { routes: Map } @@ -620,8 +661,8 @@ describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { const result = await adapter.renderJourney('t-fallback', sampleJourney); - // canvases.create was attempted - expect(canvasCreate).toHaveBeenCalledTimes(1); + // conversations.canvases.create was attempted + expect(conversationsCanvasesCreate).toHaveBeenCalledTimes(1); // chat.postMessage was called with fallback blocks expect(postMessage).toHaveBeenCalledTimes(1); const msgArg = postMessage.mock.calls[0]?.[0] as Record; @@ -637,9 +678,9 @@ describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { expect(result).toContain('canvas unavailable'); }); - it('does not throw even when both canvases.create and chat.postMessage succeed on fallback', async () => { + it('does not throw even when both conversations.canvases.create and chat.postMessage succeed on fallback', async () => { const postMessage = vi.fn().mockResolvedValue({}); - const canvasCreate = vi.fn().mockRejectedValue(new Error('not_allowed')); + const conversationsCanvasesCreate = vi.fn().mockRejectedValue(new Error('not_allowed')); const adapter = new SlackAdapter({ slackBotToken: 'xoxb-test', slackAppToken: 'xapp-test', @@ -647,7 +688,8 @@ describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { (adapter as unknown as { app: { client: unknown } }).app.client = { chat: { postMessage }, assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, - canvases: { create: canvasCreate }, + conversations: { canvases: { create: conversationsCanvasesCreate } }, + files: { info: vi.fn(), uploadV2: vi.fn().mockResolvedValue({}) }, }; ( adapter as unknown as { routes: Map } @@ -660,7 +702,7 @@ describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { const { adapter } = makeAdapterWithCanvas(); ( adapter as unknown as { routes: Map } - ).routes.set('t-bad', { channel: 'C50', threadTs: 'T50' }); + ).routes.set('t-bad', { channel: 'C60', threadTs: 'T60' }); await expect(adapter.renderJourney('t-bad', { not: 'a chain' })).rejects.toThrow( 'not a valid CausalChain', diff --git a/packages/connector-slack/src/slack-adapter.ts b/packages/connector-slack/src/slack-adapter.ts index 44db6d5..1e9b973 100644 --- a/packages/connector-slack/src/slack-adapter.ts +++ b/packages/connector-slack/src/slack-adapter.ts @@ -194,24 +194,36 @@ export class SlackAdapter implements SurfaceAdapter { const markdown = journeyMarkdown(journey); const title = `Session journey — ${journey.error.level.toUpperCase()}: ${journey.error.message.slice(0, 60)}`; - // Primary path: create a Slack canvas. - // canvases.create returns { canvas_id } (no URL field in the API). + // Primary path: create a channel-linked Slack canvas. + // conversations.canvases.create({ channel_id, title, document_content }) creates a canvas + // that channel members can see and open. Returns { canvas_id } (no URL field in the API). // On any canvas error (free team, canvas_disabled_user_team, etc.) fall back to Block Kit. let canvasId: string | undefined; try { - const result = await this.app.client.canvases.create({ + const result = await this.app.client.conversations.canvases.create({ + channel_id: r.channel, title, document_content: { type: 'markdown', markdown }, }); canvasId = result.canvas_id; - } catch { + } catch (err) { + console.warn('[peek/connector-slack] canvas unavailable:', err); // Canvas unavailable — fall through to Block Kit fallback } if (canvasId !== undefined) { - // Post a confirmation message referencing the canvas id into the thread. - // Slack renders canvas references when the canvas_id is mentioned. - const text = `🗺 Session journey canvas created. Canvas ID: \`${canvasId}\``; + // Resolve the permalink via files.info so we can post a clickable mrkdwn link. + // The conversations.canvases.create response has canvas_id only (no URL). + let permalink: string | undefined; + try { + const info = await this.app.client.files.info({ file: canvasId }); + permalink = info.file?.permalink; + } catch (err) { + console.warn('[peek/connector-slack] files.info failed for canvas permalink:', err); + } + + const linkText = permalink ? `<${permalink}|Session journey>` : `Canvas ID: \`${canvasId}\``; + const text = `🗺 Session journey canvas created. ${linkText}`; await this.app.client.chat.postMessage({ channel: r.channel, ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), @@ -219,7 +231,7 @@ export class SlackAdapter implements SurfaceAdapter { blocks: confirmation(text) as any, text, }); - return text; + return permalink ?? text; } // Fallback: Block Kit timeline summary posted directly to the thread. From a62944bdc792c36e5158f2e68894e0fcc5808505 Mon Sep 17 00:00:00 2001 From: Harish Kumar <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:43:06 +0530 Subject: [PATCH 6/9] =?UTF-8?q?fix(connector-slack):=20never=20post=20a=20?= =?UTF-8?q?dead=20canvas=20id=20=E2=80=94=20Block=20Kit=20fallback=20when?= =?UTF-8?q?=20no=20permalink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Harish Kumar <22562634+harry-harish@users.noreply.github.com> --- .../connector-slack/src/slack-adapter.test.ts | 54 +++++++++++++++++++ packages/connector-slack/src/slack-adapter.ts | 27 ++++++---- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/connector-slack/src/slack-adapter.test.ts b/packages/connector-slack/src/slack-adapter.test.ts index e00619b..3622d98 100644 --- a/packages/connector-slack/src/slack-adapter.test.ts +++ b/packages/connector-slack/src/slack-adapter.test.ts @@ -637,6 +637,60 @@ describe('SlackAdapter.renderJourney — canvas path', () => { // Confirmation string indicates fallback was used expect(result).toContain('canvas unavailable'); }); + + it('falls back to Block Kit when canvas is created but files.info yields no permalink', async () => { + // files.info resolves but returns a file object with no permalink field + const { adapter, postMessage, conversationsCanvasesCreate, filesInfo } = makeAdapterWithCanvas({ + canvas_id: 'Fx', + }); + filesInfo.mockResolvedValue({ file: {} }); // no permalink + ( + adapter as unknown as { routes: Map } + ).routes.set('t-no-permalink', { channel: 'C60', threadTs: 'T60' }); + + const result = await adapter.renderJourney('t-no-permalink', sampleJourney); + + // conversations.canvases.create and files.info were both called + expect(conversationsCanvasesCreate).toHaveBeenCalledTimes(1); + expect(filesInfo).toHaveBeenCalledTimes(1); + // Must fall through to Block Kit — one postMessage with blocks array + expect(postMessage).toHaveBeenCalledTimes(1); + const msgArg = postMessage.mock.calls[0]?.[0] as Record; + expect(Array.isArray(msgArg.blocks)).toBe(true); + // Must NOT post a bare canvas id + const msgText = msgArg.text as string; + expect(msgText).not.toContain('Canvas ID:'); + expect(msgText).not.toContain('Fx'); + // Return value indicates canvas unavailable, not the raw canvas id + expect(result).toContain('canvas unavailable'); + expect(result).not.toContain('Fx'); + }); + + it('falls back to Block Kit when canvas is created but files.info rejects (throws)', async () => { + // files.info throws — same Block Kit fallback must fire, no dead id posted + const { adapter, postMessage, conversationsCanvasesCreate, filesInfo } = makeAdapterWithCanvas({ + canvas_id: 'Fx', + }); + filesInfo.mockRejectedValue(new Error('files.info network error')); + ( + adapter as unknown as { routes: Map } + ).routes.set('t-filesinfo-throws', { channel: 'C70', threadTs: 'T70' }); + + const result = await adapter.renderJourney('t-filesinfo-throws', sampleJourney); + + expect(conversationsCanvasesCreate).toHaveBeenCalledTimes(1); + expect(filesInfo).toHaveBeenCalledTimes(1); + // Must fall through to Block Kit — one postMessage with blocks array + expect(postMessage).toHaveBeenCalledTimes(1); + const msgArg = postMessage.mock.calls[0]?.[0] as Record; + expect(Array.isArray(msgArg.blocks)).toBe(true); + // Must NOT post a bare canvas id + const msgText = msgArg.text as string; + expect(msgText).not.toContain('Canvas ID:'); + expect(msgText).not.toContain('Fx'); + expect(result).toContain('canvas unavailable'); + expect(result).not.toContain('Fx'); + }); }); describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { diff --git a/packages/connector-slack/src/slack-adapter.ts b/packages/connector-slack/src/slack-adapter.ts index 1e9b973..9de1486 100644 --- a/packages/connector-slack/src/slack-adapter.ts +++ b/packages/connector-slack/src/slack-adapter.ts @@ -222,16 +222,23 @@ export class SlackAdapter implements SurfaceAdapter { console.warn('[peek/connector-slack] files.info failed for canvas permalink:', err); } - const linkText = permalink ? `<${permalink}|Session journey>` : `Canvas ID: \`${canvasId}\``; - const text = `🗺 Session journey canvas created. ${linkText}`; - await this.app.client.chat.postMessage({ - channel: r.channel, - ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), - // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here - blocks: confirmation(text) as any, - text, - }); - return permalink ?? text; + // Only post a canvas confirmation if we have a clickable link. + // A bare canvas_id is NOT openable — treat a missing permalink as canvas-failure + // and fall through to the Block Kit summary below (never post a dead id). + if (permalink) { + const text = `🗺 Session journey canvas created. <${permalink}|Session journey>`; + await this.app.client.chat.postMessage({ + channel: r.channel, + ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), + // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here + blocks: confirmation(text) as any, + text, + }); + return permalink; + } + console.warn( + '[peek/connector-slack] canvas created but no permalink resolved — using Block Kit fallback', + ); } // Fallback: Block Kit timeline summary posted directly to the thread. From 37ca4ff34c3824b6f0dae33f2f33ecab6cea78e5 Mon Sep 17 00:00:00 2001 From: Harish Kumar <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:49:51 +0530 Subject: [PATCH 7/9] =?UTF-8?q?docs(peek):=20THREATMODEL=20=E2=80=94=20ren?= =?UTF-8?q?der=5Fsession=5Fjourney=20egress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add attack-surface #10 and a full egress section for the render_session_journey canvas render path: user-initiated @peek mention as consent, derived CausalChain (not raw bundle) to a channel-linked Slack canvas (canvases:write) with Block Kit fallback, honest residual limits (Slack retention, channel visibility, capture-time masking). Verify @peekdev/mcp minor changeset render-session-journey-tool.md is correct; no connector changeset needed (private packages). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Harish Kumar <22562634+harry-harish@users.noreply.github.com> --- docs/peek/THREATMODEL.md | 80 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/peek/THREATMODEL.md b/docs/peek/THREATMODEL.md index 575c3b8..2e4b248 100644 --- a/docs/peek/THREATMODEL.md +++ b/docs/peek/THREATMODEL.md @@ -46,6 +46,12 @@ project's pre-launch supply-chain hygiene controls (see session bundle leaves the local store and is uploaded to a third-party cloud (e.g. Slack via `files.uploadV2`). Trust boundary: peek local store → third-party cloud service. +10. **`render_session_journey` canvas render** — a second egress path where a + session's derived CausalChain (timeline, narrative, error tables) is sent to a + channel-linked Slack canvas (`conversations.canvases.create`) or Block Kit + message. Triggered by explicit `@peek rebuild the journey` mention. Trust + boundary: peek local store → Slack's cloud (wider than a one-line answer, + narrower than the raw bundle). ## Mitigations already in place @@ -264,6 +270,80 @@ audit log. | `share_session` egress to Slack | Recorded session data (masked DOM + console/network) leaves the local store and enters Slack's cloud | `accepted` (explicit consent required) | Gated by an elicitation consent card before any file is written. Temp file deleted after upload. Audit log records the export. Residual: Slack retention applies post-upload; masking completeness depends on capture-time config. | | Temp `.peekbundle` file on disk | Brief window between bundle write and upload deletion; a local process could read the file | `accepted` (local-peer-trust class) | Temp file has a random-nonce suffix. Sits in OS temp dir with normal umask. Deleted on success and on failure (best-effort). The window is bounded by the upload round-trip latency. | +## Session egress — `render_session_journey` (session-journey / canvas render) + +SP7 introduces a second egress path: the `render_session_journey` MCP tool returns a +session's **derived CausalChain** to the active connector for rich rendering +(e.g. a channel-linked Slack canvas). Like `share_session` it sends session-derived +content to a third-party cloud, but the nature of the egress is different. + +### What the journey contains + +A CausalChain is a **summarised, structured representation** of a session: an +ordered timeline of user actions, DOM mutations, network events, and console +errors; a human-readable narrative; masked field values; and error tables. It is +generated at render time from the locally stored session by the same pipeline as +`get_user_action_before_error`. It is **not** the raw rrweb event stream. No +additional masking is applied by the connector — masking is a capture-time +transformation applied by `peek-extension/src/relay/mask.ts` before any event +reaches the local store. + +### Consent gate + +`render_session_journey` is initiated by an explicit **`@peek rebuild the journey`** +mention in a Slack channel. The user's mention is the consent event — there is no +separate consent card. This distinguishes the journey path from `share_session`, +which always presents an explicit elicitation consent card before writing any file. +The design rationale is that the command is narrowly scoped (the journey is a +summarised read-path output, not the raw bundle) and the user explicitly named the +action. If the user does not send the command, the canvas is never created. + +### Slack render path + +When the connector is `@peekdev/connector-slack`, the CausalChain is rendered to +a **channel-linked canvas** via `conversations.canvases.create`. This requires the +`canvases:write` OAuth scope on the Slack app (in addition to the scopes already +required by SP3b/SP5). A clickable permalink to the canvas is posted in the same +Slack thread. When `conversations.canvases.create` is unavailable (e.g. the scope +is absent or the API returns an error), the connector falls back to a Block Kit +message in the thread containing the journey narrative and key timeline entries. + +Neither path writes a temp file to disk — the CausalChain is serialised in memory +and transmitted directly to Slack's API. + +### How this differs from `share_session` + +| Property | `share_session` | `render_session_journey` | +|---|---|---| +| Content | Raw `.peekbundle` (full rrweb event stream) | Derived CausalChain (timeline + narrative + error tables) | +| Consent | Explicit elicitation card before any data leaves | User-initiated `@peek rebuild the journey` mention | +| Slack surface | File upload (`files.uploadV2`) | Channel-linked canvas (`conversations.canvases.create`) or Block Kit fallback | +| Temp file on disk | Yes (deleted after upload) | No | +| Egress breadth | Full session (masked) | Derived summary (masked) — wider than a one-line answer, narrower than the raw bundle | + +### Residual limits (honest framing) + +- **Once the canvas is in Slack it is under Slack's retention and access + controls**, not peek's. peek's local-first guarantees do not extend past + the API call boundary. Channel-linked canvases are visible to all channel + members. Workspace admins, eDiscovery exports, and Slack's own data-retention + policies apply. +- **Masking is capture-time, not security redaction.** If the masking + configuration at record time was incomplete (e.g. a custom input field not + covered by the default mask rules), the unmasked value may appear in the + CausalChain. Users should treat the canvas content with the same care as the + original session. +- **The journey is broader than a one-line answer.** A full timeline + narrative + for a complex session may contain more detail than a user expects. Users should + use the command only in channels where the session content is appropriate to share. + +### Attack surface rows + +| Surface | Threat | Grade | Notes | +|---|---|---|---| +| `render_session_journey` egress to Slack canvas | Derived session data (timeline, narrative, error tables) leaves the local store and enters Slack's cloud | `accepted` (user-initiated command) | Gated by the user explicitly sending `@peek rebuild the journey`. Canvas is channel-linked; visible to channel members. Audit log records the render. Residual: Slack retention applies post-render; masking completeness depends on capture-time config. | +| Block Kit fallback | Journey narrative posted as a Slack message when canvas API is unavailable | `accepted` (same user-initiation gate) | Same consent and content as the canvas path; narrower surface (message vs. canvas) but equally persistent under Slack retention. | + ## Cross-references - [`docs/peek/PRIVACY_POLICY.md`](PRIVACY_POLICY.md) From 9259ba2d2f5cfc845a8ba675e1172d36c7067c0a Mon Sep 17 00:00:00 2001 From: Harish Kumar <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:59:19 +0530 Subject: [PATCH 8/9] fix(connector-slack): journey null-status net rows + guard error.level Whole-branch review minors, fixed pre-PR: - N1: a net-level failure serializes as JSON status:null (peek-mcp emits number|null); the fallback table rendered the literal "null" instead of errorText. Use `!= null` and widen the local type to number|null. - N2: isJourneyCausalChain now validates error.level (dereferenced via .toUpperCase() by both renderers), not just error.message, keeping renderJourney total on malformed input. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Harish Kumar <22562634+harry-harish@users.noreply.github.com> --- packages/connector-slack/src/journey.test.ts | 28 ++++++++++++++++++++ packages/connector-slack/src/journey.ts | 13 ++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/connector-slack/src/journey.test.ts b/packages/connector-slack/src/journey.test.ts index 5981d80..996aa11 100644 --- a/packages/connector-slack/src/journey.test.ts +++ b/packages/connector-slack/src/journey.test.ts @@ -168,6 +168,25 @@ describe('journeyMarkdown', () => { expect(md).toContain('#btn-149'); }); + it('renders errorText for a net-level failure with null status (not literal "null")', () => { + // peek-mcp emits `status: null` for a net-level failure (no HTTP status); + // JSON round-trips it as null, so the renderer must fall through to errorText. + const journey = makeJourney({ + networkErrors: [ + { + ts: 1200, + method: 'GET', + url: '/api/data', + status: null, + errorText: 'ERR_CONNECTION_REFUSED', + }, + ], + }); + const md = journeyMarkdown(journey); + expect(md).toContain('| GET | /api/data | ERR_CONNECTION_REFUSED |'); + expect(md).not.toContain('| null |'); + }); + it('caps network error table at MAX_TABLE_ROWS (25) with a "+N more" row', () => { const manyNet = Array.from({ length: 40 }, (_, i) => ({ ts: 1000 + i, @@ -290,4 +309,13 @@ describe('isJourneyCausalChain', () => { it('returns false when timeline is not an array', () => { expect(isJourneyCausalChain({ ...makeJourney(), timeline: 'not-array' })).toBe(false); }); + + it('returns false when error.level is missing (renderers dereference it)', () => { + const journey = makeJourney(); + const malformed = { + ...journey, + error: { id: 1, ts: 2000, message: 'boom' }, // no level + }; + expect(isJourneyCausalChain(malformed)).toBe(false); + }); }); diff --git a/packages/connector-slack/src/journey.ts b/packages/connector-slack/src/journey.ts index 7af7609..e1a9007 100644 --- a/packages/connector-slack/src/journey.ts +++ b/packages/connector-slack/src/journey.ts @@ -31,7 +31,9 @@ export interface JourneyNetworkError { readonly ts: number; readonly method: string; readonly url: string; - readonly status?: number; + // Producer (peek-mcp) emits `number | null` — a net-level failure with no + // HTTP status serializes as JSON `null`, not absent. Accept both. + readonly status?: number | null; readonly errorText?: string; } @@ -162,7 +164,10 @@ export function journeyMarkdown(journey: JourneyCausalChain): string { const hiddenNet = networkErrors.length - visibleNet.length; for (const n of visibleNet) { - const status = n.status !== undefined ? String(n.status) : (n.errorText ?? 'error'); + // `!= null` catches both undefined (absent) and null (net-level failure, + // no HTTP status) — a `null` row must fall through to errorText, not + // render the literal "null". + const status = n.status != null ? String(n.status) : (n.errorText ?? 'error'); const url = cap(n.url, 80); lines.push(`| ${n.method} | ${url} | ${status} |`); } @@ -283,5 +288,7 @@ export function isJourneyCausalChain(v: unknown): v is JourneyCausalChain { const err = o.error; if (typeof err !== 'object' || err === null) return false; const e = err as Record; - return typeof e.message === 'string'; + // `level` is dereferenced (`.toUpperCase()`) by the renderers, so guard it + // too — not just `message` — to keep renderJourney total on malformed input. + return typeof e.message === 'string' && typeof e.level === 'string'; } From 8f66aac9f7dfb5b4d77c66f82d0bbe1eb67588bf Mon Sep 17 00:00:00 2001 From: Harish Kumar <22562634+harry-harish@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:21:37 +0530 Subject: [PATCH 9/9] =?UTF-8?q?fix(peek):=20harden=20session-journey=20per?= =?UTF-8?q?=20CodeRabbit=20=E2=80=94=20latest-error=20query,=20total=20ren?= =?UTF-8?q?derers,=20header=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Harish Kumar <22562634+harry-harish@users.noreply.github.com> --- packages/connector-core/src/runtime.test.ts | 47 ++++++++++++ packages/connector-core/src/runtime.ts | 8 ++- packages/connector-slack/src/journey.test.ts | 61 ++++++++++++++++ packages/connector-slack/src/journey.ts | 37 ++++++++-- .../connector-slack/src/slack-adapter.test.ts | 64 +++++++++++++++++ packages/connector-slack/src/slack-adapter.ts | 59 +++++++++------ packages/peek-mcp/src/mcp/queries.ts | 14 ++++ packages/peek-mcp/src/mcp/server.ts | 9 ++- .../test/render-session-journey.test.ts | 72 +++++++++++++++++++ 9 files changed, 341 insertions(+), 30 deletions(-) diff --git a/packages/connector-core/src/runtime.test.ts b/packages/connector-core/src/runtime.test.ts index 8533666..1f6ae54 100644 --- a/packages/connector-core/src/runtime.test.ts +++ b/packages/connector-core/src/runtime.test.ts @@ -1446,4 +1446,51 @@ describe('render_session_journey interception — REAL SdkBrain inline (read) ro // renderJourney was NOT called. expect(adapter.renderJourneyCalls).toHaveLength(0); }); + + it('(g) adapter.renderJourney throws → interceptCallTool degrades to a safe note, no throw, no data leak', async () => { + // renderJourney must never throw into the tool loop (Fix #1). A throwing + // adapter must degrade to a brief note that does NOT echo the CausalChain JSON. + const toolResult = JSON.stringify(CAUSAL_CHAIN_FIXTURE); + + // An adapter whose renderJourney rejects (e.g. Slack canvas AND fallback both fail). + class ThrowingJourneyAdapter extends JourneyAdapter { + override async renderJourney(): Promise { + throw new Error('slack canvas + fallback both failed'); + } + } + const adapter = new ThrowingJourneyAdapter(); + + // Drive interception through a started runtime so #activeConversationId is set + // for the duration of the turn — the throwing branch is only reachable with an + // active conversationId. The tracking brain intercepts inside its runTurn. + let result = ''; + // biome-ignore lint/style/useConst: forward reference for a construction-time dependency cycle + let runtimeRef: ConnectorRuntime; + const trackingBrain: Brain = { + newSession: (): Session => ({ history: [] }), + appendUserText: () => {}, + appendToolResult: () => {}, + runTurn: async () => { + result = await runtimeRef.interceptCallTool( + 'render_session_journey', + { sessionId: 's1', errorId: 42 }, + async () => toolResult, + ); + return { kind: 'done' as const, text: 'ok' }; + }, + }; + const store = new SessionStore(() => trackingBrain.newSession()); + const mcp = { callTool: vi.fn(), onElicit: () => {} } as unknown as PeekMcp; + const runtime = new ConnectorRuntime({ adapter, brain: trackingBrain, mcp, store }); + runtimeRef = runtime; + await runtime.start(); + + adapter.msgHandler?.({ conversationId: 'c1', userId: 'u', text: 'journey' }); + await vi.waitFor(() => expect(result.length).toBeGreaterThan(0)); + + // Degraded safely: no throw, brief note, no CausalChain fields leaked. + expect(result).toContain('could not render'); + expect(result).not.toContain('"timeline"'); + expect(result).not.toContain('"narrative"'); + }); }); diff --git a/packages/connector-core/src/runtime.ts b/packages/connector-core/src/runtime.ts index e3b6d7c..98efb02 100644 --- a/packages/connector-core/src/runtime.ts +++ b/packages/connector-core/src/runtime.ts @@ -243,7 +243,13 @@ export class ConnectorRuntime { return text; } if (adapter.renderJourney && conversationId) { - return await adapter.renderJourney(conversationId, parsed); + try { + return await adapter.renderJourney(conversationId, parsed); + } catch { + // renderJourney must never throw into the tool loop — degrade to a safe note. + // (Do NOT echo `text`/`parsed` — that would leak the full CausalChain JSON.) + return 'Session journey is ready, but this surface could not render it.'; + } } // Degrade: no renderJourney support or no active conversation — return a brief note. return 'Session journey is ready, but this surface cannot render it.'; diff --git a/packages/connector-slack/src/journey.test.ts b/packages/connector-slack/src/journey.test.ts index 996aa11..0adda41 100644 --- a/packages/connector-slack/src/journey.test.ts +++ b/packages/connector-slack/src/journey.test.ts @@ -277,6 +277,24 @@ describe('journeyBlocks', () => { ); expect(hasTruncation).toBe(false); }); + + it('keeps the header plain_text within Slack’s 150-char limit for a very long error message', () => { + // Slack `header` blocks hard-limit plain_text to 150 chars. The prefix + // ("ERROR: ") plus the capped message must fit, so a 300-char message must + // not push the combined header text over 150. + const longMessage = 'X'.repeat(300); + const journey = makeJourney({ + error: { id: 1, ts: 2000, level: 'error', message: longMessage }, + }); + const blocks = journeyBlocks(journey); + const header = blocks.find((b) => b.type === 'header') as + | { text: { text: string } } + | undefined; + expect(header).toBeDefined(); + expect(header?.text.text.length).toBeLessThanOrEqual(150); + // The prefix is preserved even when the message is capped. + expect(header?.text.text.startsWith('ERROR: ')).toBe(true); + }); }); // --------------------------------------------------------------------------- @@ -318,4 +336,47 @@ describe('isJourneyCausalChain', () => { }; expect(isJourneyCausalChain(malformed)).toBe(false); }); + + it('returns false when a timeline entry has a non-string summary (renderers cap() it)', () => { + const journey = makeJourney(); + const malformed = { + ...journey, + timeline: [ + { ts: 1000, relMs: -500, kind: 'action', summary: 42 }, // summary not a string + ...journey.timeline, + ], + }; + expect(isJourneyCausalChain(malformed)).toBe(false); + }); + + it('returns false when a timeline entry is missing relMs (renderers read it)', () => { + const journey = makeJourney(); + const malformed = { + ...journey, + timeline: [{ ts: 1000, kind: 'action', summary: 'click #x' }], // no relMs + }; + expect(isJourneyCausalChain(malformed)).toBe(false); + }); + + it('returns false when networkErrors is missing', () => { + const { networkErrors: _removed, ...rest } = makeJourney(); + expect(isJourneyCausalChain(rest)).toBe(false); + }); + + it('returns false when networkErrors is not an array', () => { + expect(isJourneyCausalChain({ ...makeJourney(), networkErrors: 'nope' })).toBe(false); + }); + + it('returns false when a networkError row lacks a string url (renderers cap() it)', () => { + const journey = makeJourney(); + const malformed = { + ...journey, + networkErrors: [{ ts: 1200, method: 'POST', status: 500 }], // no url + }; + expect(isJourneyCausalChain(malformed)).toBe(false); + }); + + it('returns true when networkErrors is an empty array (well-formed)', () => { + expect(isJourneyCausalChain(makeJourney({ networkErrors: [] }))).toBe(true); + }); }); diff --git a/packages/connector-slack/src/journey.ts b/packages/connector-slack/src/journey.ts index e1a9007..76b8488 100644 --- a/packages/connector-slack/src/journey.ts +++ b/packages/connector-slack/src/journey.ts @@ -219,11 +219,15 @@ export function journeyBlocks(journey: JourneyCausalChain): KnownBlock[] { const blocks: KnownBlock[] = []; - // Header - const headline = cap(error.message, 150); + // Header — Slack `header` blocks hard-limit plain_text to 150 chars, so reserve + // the level-prefix budget before capping the message (prefix + headline <= 150). + // `cap` appends a 1-char "…" on truncation, so a header block never overflows + // even when the capped headline hits the boundary. + const prefix = `${error.level.toUpperCase()}: `; + const headline = cap(error.message, Math.max(0, 150 - prefix.length - 1)); blocks.push({ type: 'header', - text: { type: 'plain_text', text: `${error.level.toUpperCase()}: ${headline}` }, + text: { type: 'plain_text', text: `${prefix}${headline}` }, }); // Narrative section @@ -276,6 +280,25 @@ export function journeyBlocks(journey: JourneyCausalChain): KnownBlock[] { // Type guard — validate that an `unknown` value looks like a CausalChain // --------------------------------------------------------------------------- +/** A timeline entry the renderers can safely dereference (kind/summary/relMs/ts). */ +function isTimelineEntry(v: unknown): boolean { + if (typeof v !== 'object' || v === null) return false; + const t = v as Record; + return ( + typeof t.kind === 'string' && + typeof t.summary === 'string' && + typeof t.relMs === 'number' && + typeof t.ts === 'number' + ); +} + +/** A network-error row the renderers can safely dereference (method/url; status/errorText optional). */ +function isNetworkError(v: unknown): boolean { + if (typeof v !== 'object' || v === null) return false; + const n = v as Record; + return typeof n.method === 'string' && typeof n.url === 'string'; +} + export function isJourneyCausalChain(v: unknown): v is JourneyCausalChain { if (typeof v !== 'object' || v === null) return false; const o = v as Record; @@ -290,5 +313,11 @@ export function isJourneyCausalChain(v: unknown): v is JourneyCausalChain { const e = err as Record; // `level` is dereferenced (`.toUpperCase()`) by the renderers, so guard it // too — not just `message` — to keep renderJourney total on malformed input. - return typeof e.message === 'string' && typeof e.level === 'string'; + if (typeof e.message !== 'string' || typeof e.level !== 'string') return false; + // The renderers dereference every timeline entry (kind/summary/relMs) and every + // networkErrors row (method/url via cap()) — deep-validate both so an invalid + // payload is rejected here, before any cap() call, keeping renderJourney total. + if (!o.timeline.every(isTimelineEntry)) return false; + if (!Array.isArray(o.networkErrors) || !o.networkErrors.every(isNetworkError)) return false; + return true; } diff --git a/packages/connector-slack/src/slack-adapter.test.ts b/packages/connector-slack/src/slack-adapter.test.ts index 3622d98..f4f0e5e 100644 --- a/packages/connector-slack/src/slack-adapter.test.ts +++ b/packages/connector-slack/src/slack-adapter.test.ts @@ -763,3 +763,67 @@ describe('SlackAdapter.renderJourney — Block Kit fallback path', () => { ); }); }); + +describe('SlackAdapter.renderJourney — delivery guards (total renderer)', () => { + it('(a) canvas confirmation postMessage rejects → does not throw and falls back to Block Kit', async () => { + // Canvas created + permalink resolved, but the confirmation post fails. The + // renderer must not throw — it falls through to the Block Kit fallback so the + // team still gets the journey. First postMessage rejects; second succeeds. + const postMessage = vi + .fn() + .mockRejectedValueOnce(new Error('chat.postMessage failed')) + .mockResolvedValue({}); + const conversationsCanvasesCreate = vi.fn().mockResolvedValue({ canvas_id: 'Fcanvas-guard' }); + const filesInfo = vi + .fn() + .mockResolvedValue({ file: { permalink: 'https://slack.com/canvas/Fcanvas-guard' } }); + const adapter = new SlackAdapter({ + slackBotToken: 'xoxb-test', + slackAppToken: 'xapp-test', + } as never); + (adapter as unknown as { app: { client: unknown } }).app.client = { + chat: { postMessage }, + assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, + conversations: { canvases: { create: conversationsCanvasesCreate } }, + files: { info: filesInfo, uploadV2: vi.fn().mockResolvedValue({}) }, + }; + ( + adapter as unknown as { routes: Map } + ).routes.set('t-conf-fail', { channel: 'C80', threadTs: 'T80' }); + + const result = await adapter.renderJourney('t-conf-fail', sampleJourney); + + // Both posts attempted: the failing confirmation, then the Block Kit fallback. + expect(postMessage).toHaveBeenCalledTimes(2); + const fallbackArg = postMessage.mock.calls[1]?.[0] as Record; + expect(Array.isArray(fallbackArg.blocks)).toBe(true); + // Fallback confirmation string returned (not the canvas permalink). + expect(result).toContain('canvas unavailable'); + }); + + it('(b) Block Kit fallback postMessage rejects → returns a string and does not throw', async () => { + // Canvas unavailable AND the fallback post fails: renderJourney must return a + // clean status string, never throw. + const postMessage = vi.fn().mockRejectedValue(new Error('fallback post failed')); + const conversationsCanvasesCreate = vi.fn().mockRejectedValue(new Error('canvas_disabled')); + const adapter = new SlackAdapter({ + slackBotToken: 'xoxb-test', + slackAppToken: 'xapp-test', + } as never); + (adapter as unknown as { app: { client: unknown } }).app.client = { + chat: { postMessage }, + assistant: { threads: { setStatus: vi.fn().mockResolvedValue({}) } }, + conversations: { canvases: { create: conversationsCanvasesCreate } }, + files: { info: vi.fn(), uploadV2: vi.fn().mockResolvedValue({}) }, + }; + ( + adapter as unknown as { routes: Map } + ).routes.set('t-fallback-fail', { channel: 'C90', threadTs: 'T90' }); + + const result = await adapter.renderJourney('t-fallback-fail', sampleJourney); + + // Did not throw; returned a clean status string. + expect(typeof result).toBe('string'); + expect(result).toContain('posting it to Slack failed'); + }); +}); diff --git a/packages/connector-slack/src/slack-adapter.ts b/packages/connector-slack/src/slack-adapter.ts index 9de1486..56d216c 100644 --- a/packages/connector-slack/src/slack-adapter.ts +++ b/packages/connector-slack/src/slack-adapter.ts @@ -227,30 +227,49 @@ export class SlackAdapter implements SurfaceAdapter { // and fall through to the Block Kit summary below (never post a dead id). if (permalink) { const text = `🗺 Session journey canvas created. <${permalink}|Session journey>`; - await this.app.client.chat.postMessage({ - channel: r.channel, - ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), - // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here - blocks: confirmation(text) as any, - text, - }); - return permalink; + // Guard the confirmation post: a Slack API error here must not throw out of + // renderJourney. On failure, fall through to the Block Kit fallback below so + // the team still gets the journey. + try { + await this.app.client.chat.postMessage({ + channel: r.channel, + ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), + // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here + blocks: confirmation(text) as any, + text, + }); + return permalink; + } catch (err) { + console.warn( + '[peek/connector-slack] canvas confirmation post failed — using Block Kit fallback:', + err, + ); + // fall through to the Block Kit fallback + } + } else { + console.warn( + '[peek/connector-slack] canvas created but no permalink resolved — using Block Kit fallback', + ); } - console.warn( - '[peek/connector-slack] canvas created but no permalink resolved — using Block Kit fallback', - ); } - // Fallback: Block Kit timeline summary posted directly to the thread. + // Fallback: Block Kit timeline summary posted directly to the thread. Guard the + // post so a Slack API error never throws out of renderJourney — the caller + // (connector-core) treats renderJourney as total. const fallbackBlocks = journeyBlocks(journey); - await this.app.client.chat.postMessage({ - channel: r.channel, - ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), - // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here - blocks: fallbackBlocks as any, - text: 'Session journey (canvas unavailable — showing summary)', - }); - return 'Session journey posted as a message (Slack canvas unavailable on this workspace).'; + try { + await this.app.client.chat.postMessage({ + channel: r.channel, + ...(r.threadTs !== undefined ? { thread_ts: r.threadTs } : {}), + // biome-ignore lint/suspicious/noExplicitAny: Bolt's postMessage accepts KnownBlock[] but its typings require `any[]` here + blocks: fallbackBlocks as any, + text: 'Session journey (canvas unavailable — showing summary)', + }); + return 'Session journey posted as a message (Slack canvas unavailable on this workspace).'; + } catch (err) { + console.warn('[peek/connector-slack] Block Kit fallback post failed:', err); + return 'Session journey is ready, but posting it to Slack failed.'; + } } private emit( diff --git a/packages/peek-mcp/src/mcp/queries.ts b/packages/peek-mcp/src/mcp/queries.ts index 6a2904a..99e2ca4 100644 --- a/packages/peek-mcp/src/mcp/queries.ts +++ b/packages/peek-mcp/src/mcp/queries.ts @@ -231,6 +231,20 @@ export function getConsoleErrorById( : undefined; } +/** The single most-recent console error row for a session (the default causal-chain seed). */ +export function getLatestConsoleError(db: Database, id: string): ConsoleErrorRow | undefined { + const r = db + .prepare( + "SELECT id, ts_ms, level, message, stack FROM console_events WHERE session_id = ? AND level = 'error' ORDER BY ts_ms DESC, id DESC LIMIT 1", + ) + .get(id) as + | { id: number; ts_ms: number; level: string; message: string; stack: string | null } + | undefined; + return r + ? { id: r.id, ts: r.ts_ms, level: r.level, message: r.message, stack: r.stack } + : undefined; +} + /** Error-ish network rows (status >= statusGte OR error_text) within [fromTs, toTs], ascending by ts. */ export function getNetworkErrorsInWindow( db: Database, diff --git a/packages/peek-mcp/src/mcp/server.ts b/packages/peek-mcp/src/mcp/server.ts index e92c419..beab731 100644 --- a/packages/peek-mcp/src/mcp/server.ts +++ b/packages/peek-mcp/src/mcp/server.ts @@ -59,6 +59,7 @@ import { generatePlaywrightRepro } from './playwright-repro.js'; import { getConsoleErrorById, getConsoleErrors, + getLatestConsoleError, getNetworkErrors, getNetworkErrorsInWindow, getSessionBlobRef, @@ -597,15 +598,13 @@ export function createPeekMcpServer(options: CreatePeekMcpServerOptions = {}): P return textResult(`No console error with id ${errorId} in session '${sessionId}'.`); } } else { - // Auto-select the session's latest console error (highest ts). - const allErrors = getConsoleErrors(handle, sessionId, { limit: 200 }); - if (allErrors.length === 0) { + // Auto-select the session's latest console error (highest ts, id tiebreak). + error = getLatestConsoleError(handle, sessionId); + if (error === undefined) { return textResult( `Session '${sessionId}' has no console errors to anchor a journey. Record a session that captures a console error, then call this tool again.`, ); } - // getConsoleErrors returns oldest-first; the last entry is the latest. - error = allErrors[allErrors.length - 1] as NonNullable; } const ev = eventsFor(sessionId); diff --git a/packages/peek-mcp/test/render-session-journey.test.ts b/packages/peek-mcp/test/render-session-journey.test.ts index b7ea9c6..ee79d4a 100644 --- a/packages/peek-mcp/test/render-session-journey.test.ts +++ b/packages/peek-mcp/test/render-session-journey.test.ts @@ -92,6 +92,38 @@ function sessionWithTwoErrors() { }; } +/** + * Build a session fixture with MANY (>200) error-level console events with + * strictly increasing ts. The very last one (highest ts) is the true "latest". + * Regression fixture for the auto-select bug: an ASC LIMIT 200 query would miss + * the real latest error in a session with more than 200 errors. + */ +function sessionWithManyErrors(count: number) { + freshIds(); + const btn = el('button', { attributes: { id: 'many' } }); + const root = documentWith([btn]); + const consoleErrors = Array.from({ length: count }, (_, i) => ({ + ts: 2000 + i, // strictly increasing; the last (i === count-1) has the highest ts + message: `error #${i}`, + stack: null, + })); + return { + id: 's_many_errors', + createdAt: '2026-07-10T00:00:00.000Z', + updatedAt: '2026-07-10T00:02:00.000Z', + url: 'https://app.test/many', + title: 'Many errors', + origin: 'https://app.test', + events: [ + metaNav('https://app.test/many', 1000), + fullSnapshot(root, 1000), + clickEvent(btn.id, 1100), + ], + consoleErrors, + networkErrors: [], + }; +} + /** Build a session fixture with NO console errors. */ function sessionWithNoErrors() { freshIds(); @@ -199,6 +231,40 @@ describe('render_session_journey: auto-select latest error', () => { await close(); } }); + + it('auto-selects the true latest error even when a session has >200 console errors', async () => { + // Regression for the ASC-LIMIT-200 bug: with 205 errors, the old auto-select + // read the 200 OLDEST (ORDER BY ts ASC LIMIT 200) and took the 200th, missing + // the real latest. The dedicated getLatestConsoleError query fixes this. + const COUNT = 205; + const { dbPath, eventsDir } = seedStore(dir, [sessionWithManyErrors(COUNT)]); + const { client, close } = await connectClient({ dbPath, eventsDir }); + try { + // The true latest error id: seed inserts in order, ids are 1..COUNT. + const errs = parseJson( + (await client.callTool({ + name: 'get_session_console_errors', + arguments: { sessionId: 's_many_errors', limit: 200, since: 2000 + (COUNT - 1) }, + })) as never, + ) as Array<{ id: number; message: string }>; + // Only the single latest error has ts === 2000 + COUNT-1. + expect(errs).toHaveLength(1); + const latestId = errs[0]?.id ?? -1; + const latestMessage = errs[0]?.message ?? ''; + expect(latestMessage).toBe(`error #${COUNT - 1}`); + + // Auto-select (no errorId): must pick the true latest, NOT the 200th oldest. + const res = await client.callTool({ + name: 'render_session_journey', + arguments: { sessionId: 's_many_errors' }, + }); + const chain = parseJson(res as never) as CausalChain; + expect(chain.errorId).toBe(latestId); + expect(chain.error.message).toBe(`error #${COUNT - 1}`); + } finally { + await close(); + } + }); }); // --------------------------------------------------------------------------- @@ -236,7 +302,13 @@ describe('render_session_journey: unknown session / no DB', () => { arguments: { sessionId: 's_does_not_exist' }, }); const text = textOf(res as never); + // peek-mcp has NO session-existence concept: an unknown sessionId and an + // existing-but-empty session both flow through the auto-select branch and + // return the SAME "no console errors" message. Assert the specific message + // (not just the id echo), and that it is plain text, not a CausalChain JSON. expect(text).toContain('s_does_not_exist'); + expect(text).toContain('has no console errors to anchor a journey'); + expect(() => JSON.parse(text)).toThrow(); } finally { await close(); }