diff --git a/.agents/skills/junior-qa/SKILL.md b/.agents/skills/junior-qa/SKILL.md index 8743258a0..c95082a91 100644 --- a/.agents/skills/junior-qa/SKILL.md +++ b/.agents/skills/junior-qa/SKILL.md @@ -123,18 +123,19 @@ pnpm --filter @sentry/junior exec vitest run tests/integration/mcp-oauth-callbac For SQL conversation storage changes, verify the resumed turn rebuilds context from SQL, not `thread-state` mirrors: conversation context must hydrate from `junior_conversation_messages` (`hydrateConversationMessages`) and pi history -from `junior_agent_steps` (`loadProjection`). In those tests, transcripts -seeded only into `thread-state` must also be persisted to SQL -(`persistConversationMessages`) before the callback runs, and the resumed -agent-run input `conversationContext` must contain the SQL-seeded messages. +from `junior_conversation_events` through the Pi adapter (`loadProjection`). In +those tests, transcripts seeded only into `thread-state` must also be persisted +to SQL (`persistConversationMessages`) before the callback runs, and the +resumed agent-run input `conversationContext` must contain the SQL-seeded +messages. ## Failure Handling If local chat fails because credentials are missing or expired, refresh the environment when appropriate with `pnpm dev:env`, then rerun the same command. If local chat fails with a `junior_conversation_messages` or -`junior_agent_steps` query error, the local Postgres schema predates the SQL -conversation storage cutover; run `pnpm cli -- upgrade`, then rerun. +`junior_conversation_events` query error, the local Postgres schema predates the +SQL conversation storage cutover; run `pnpm cli -- upgrade`, then rerun. If Redis errors appear during ordinary local QA, check whether `JUNIOR_STATE_ADAPTER=redis` was set; local chat normally defaults to memory state. diff --git a/openspec/changes/sql-conversation-storage/specs/agent-step-history/spec.md b/openspec/changes/sql-conversation-storage/specs/agent-step-history/spec.md deleted file mode 100644 index 35b43c5c4..000000000 --- a/openspec/changes/sql-conversation-storage/specs/agent-step-history/spec.md +++ /dev/null @@ -1,81 +0,0 @@ -# agent-step-history - -## ADDED Requirements - -### Requirement: Execution history is stored as one row per agent step - -The system SHALL store the durable model execution history in `junior_agent_steps` with one row per step: `conversation_id` (FK to `junior_conversations`), `seq` (per-conversation order), `context_epoch`, `type` (discriminant), optional `role` (denormalized for `pi_message` rows), `payload` JSON, and `created_at`. The primary key SHALL be `(conversation_id, seq)`. The table SHALL replace the Redis list at `junior:agent-session-log:` as the execution-history authority. Rows are append-only; deletion happens only through retention purge or erasure. - -#### Scenario: Steps appended at a safe boundary - -- **WHEN** a worker commits model and tool activity at a safe boundary -- **THEN** each step is one row and reading the conversation's steps returns them in `seq` order - -### Requirement: Sequence assignment is lease-fenced and fails loudly - -`seq` SHALL be assigned transactionally per conversation by a writer holding the conversation lease. A conflicting write SHALL fail with a primary-key violation rather than silently interleaving or overwriting. - -#### Scenario: Fencing violation surfaces as an error - -- **WHEN** a writer that lost its lease attempts to append with a `seq` already used -- **THEN** the insert fails with a constraint error and no stored row is modified - -### Requirement: The current context epoch is the model context - -The model-visible context for a conversation SHALL be exactly the `pi_message` steps in the conversation's highest `context_epoch`, ordered by `seq`. Epochs start at 0. Steps in older epochs SHALL remain readable as audit history and SHALL NOT contribute to model context. Host-only step types (activity, auth, provider-connection facts) SHALL be excluded from the Pi projection as today. - -#### Scenario: Resume restores context from the current epoch - -- **WHEN** a queue-driven worker resumes a conversation -- **THEN** it restores `agent.state.messages` from the `pi_message` rows of the highest epoch in `seq` order and calls `continue()` - -### Requirement: Compaction and rollback start a new epoch atomically - -Context rebuilds SHALL NOT store embedded transcript arrays. Compaction and safe-boundary rollback SHALL, in one transaction: append a `context_epoch_started` marker step carrying `reason` (`compaction` | `rollback`), then append the replacement context as ordinary `pi_message` rows in the new epoch, preserving each message's original timestamp so replay is byte-stable. - -#### Scenario: Compaction writes a new epoch - -- **WHEN** context compaction runs for a conversation in epoch N -- **THEN** epoch N+1 contains a `context_epoch_started {reason: "compaction"}` marker followed by the retained messages and the synthetic compaction summary as individual rows, and epoch N remains as audit history - -#### Scenario: Provider-retry rollback - -- **WHEN** a transient provider failure requires trimming trailing failed assistant output -- **THEN** a new epoch is written with `reason: "rollback"` containing the trimmed history, and `continue()` resumes from it - -### Requirement: Subagent histories are child conversations - -A subagent (advisor) execution SHALL be recorded as its own conversation row with `parent_conversation_id` set, and its steps stored in `junior_agent_steps` under its own `conversation_id`. The `subagent_started` step SHALL reference the child by `childConversationId`. The polymorphic `transcriptRef {type, key}` reference and the ad-hoc `junior::advisor_session` Redis key SHALL be removed. Reading a subagent transcript SHALL use the same query path as any conversation. - -#### Scenario: Advisor invocation creates a child conversation - -- **WHEN** the advisor tool runs inside a parent conversation -- **THEN** a child conversation row exists with `parent_conversation_id` pointing at the parent, the parent's `subagent_started` step carries the child's `conversationId`, and the child's transcript renders through the standard conversation read path - -#### Scenario: Top-level listings exclude children - -- **WHEN** the dashboard lists recent conversations -- **THEN** conversations with a non-null `parent_conversation_id` are excluded - -### Requirement: Strict envelope, permissive payload - -Row envelope fields (`conversation_id`, `seq`, `context_epoch`, `type`, `created_at`) SHALL be strictly validated with the existing Zod step-type union at the store boundary; an unknown `type` or invalid envelope SHALL fail loudly as corrupt state. `pi_message` payload content SHALL remain permissive (Pi SDK owns the message shape) and SHALL carry its payload `schemaVersion` per row. - -#### Scenario: Corrupt row fails loudly - -- **WHEN** a read encounters a row whose envelope fails validation -- **THEN** the read fails with an explicit error instead of guessing behavior for the unknown shape - -### Requirement: One-time migration from Redis - -`junior upgrade` SHALL bulk-import legacy Redis session logs (bounded, newest-first), translating `sessionId` markers to integer epochs, exploding `projection_reset` entries into `context_epoch_started` markers plus per-message rows, converting advisor session keys into child conversations, and normalizing legacy v1 entry shapes. Import SHALL be idempotent per conversation (skip when step rows already exist). For conversations touched by the old deployment during promotion, the first read that finds no SQL rows while a Redis log exists SHALL perform a one-time lazy import under the conversation lease. Backfilled `pi_message` rows SHALL take `created_at` from message-internal timestamps when present, else fall back to the conversation's timestamps; fabricated import-time timestamps SHALL NOT be used. The lazy-import path SHALL be removed after the legacy Redis TTL horizon passes. - -#### Scenario: Bulk backfill during upgrade - -- **WHEN** `junior upgrade` runs against a database with legacy Redis session logs present -- **THEN** each imported conversation's steps exist in SQL with correct epochs and ordering, and re-running the upgrade imports nothing twice - -#### Scenario: Straggler conversation lazily imported - -- **WHEN** a worker resumes a conversation that has a Redis session log but no SQL step rows -- **THEN** the worker imports the log once under the conversation lease before continuing execution diff --git a/openspec/changes/sql-conversation-storage/specs/conversation-messages/spec.md b/openspec/changes/sql-conversation-storage/specs/conversation-messages/spec.md deleted file mode 100644 index be937f825..000000000 --- a/openspec/changes/sql-conversation-storage/specs/conversation-messages/spec.md +++ /dev/null @@ -1,44 +0,0 @@ -# conversation-messages - -## ADDED Requirements - -### Requirement: Visible messages are stored as SQL rows - -The system SHALL store the visible conversation transcript in `junior_conversation_messages` with one row per message, keyed by `(conversation_id, message_id)` where `conversation_id` is the globally unique conversation key and `message_id` is the source-scoped message identity (Slack `ts`-derived, local sequence). Each row SHALL carry `role` (`user` | `assistant` | `system`), `text`, an optional `author_identity_id` FK to `junior_identities`, optional `meta` JSON for bounded source facts, and `created_at`. - -#### Scenario: Message recorded and queryable - -- **WHEN** an inbound user message is accepted for a conversation -- **THEN** one message row exists for that `(conversation_id, message_id)` and listing the conversation's messages returns it in `created_at` order - -#### Scenario: Duplicate recording is idempotent - -- **WHEN** the same source message is recorded twice (source retry or redelivery) -- **THEN** exactly one row exists for that `(conversation_id, message_id)` - -### Requirement: Source facts are immutable; delivery marks are explicitly updatable - -Message rows SHALL treat `role`, `text`, `author_identity_id`, and `created_at` as immutable after insert. Mutable bookkeeping SHALL be limited to the `replied_at` delivery mark and wholesale refresh of the bounded `meta` JSON when the same message is idempotently re-recorded (late vision hydration, routing/skip marks); no other field of a stored message may be updated in place. - -#### Scenario: Reply mark set without content mutation - -- **WHEN** delivery finalizes an assistant reply that answers a stored user message -- **THEN** the user message's `replied_at` is set and its `text`, `role`, and `created_at` are unchanged - -### Requirement: SQL is the single visible-transcript authority - -The `conversation.messages` and `conversation.piMessages` mirrors in Redis `thread-state:` SHALL be removed. Reply policy, channel-context assembly, and reporting SHALL read visible messages through the `ConversationMessageStore` port. `thread-state` SHALL retain only runtime scratch (artifacts, sandbox identity, processing state) with a single Junior-owned TTL constant and a single writer. - -#### Scenario: Reply policy reads from the store - -- **WHEN** the runtime evaluates channel context or reply policy for a conversation -- **THEN** message history is read through `ConversationMessageStore` and no transcript data is read from `thread-state` - -### Requirement: Store port boundary - -Runtime, services, ingress, and dashboard modules SHALL depend on the `ConversationMessageStore` port. Drizzle client, table, and ORM types SHALL NOT leak outside `chat/conversations/sql/`. - -#### Scenario: Consumer imports the port - -- **WHEN** a runtime module needs visible message history -- **THEN** it imports the store interface, not Drizzle schema or client types diff --git a/openspec/changes/sql-conversation-storage/specs/conversation-retention/spec.md b/openspec/changes/sql-conversation-storage/specs/conversation-retention/spec.md deleted file mode 100644 index 927af903d..000000000 --- a/openspec/changes/sql-conversation-storage/specs/conversation-retention/spec.md +++ /dev/null @@ -1,68 +0,0 @@ -# conversation-retention - -## ADDED Requirements - -### Requirement: Content retention follows conversation visibility from last activity - -Conversation content (message rows, step rows, and descendant conversations' content) SHALL be retained for `window(visibility)` after the conversation's `last_activity_at`: 90 days when the root conversation's destination has persisted visibility `public`, 14 days otherwise. Any visibility other than persisted `public` — including `private`, `direct`, `unknown`, and a missing destination — SHALL resolve to the private window (fail closed). Windows SHALL be owned by named policy constants; storage write paths SHALL NOT accept or apply per-write TTLs. - -#### Scenario: Private conversation expires at 14 days - -- **WHEN** a private conversation's `last_activity_at` is more than 14 days old at purge time -- **THEN** its content is deleted - -#### Scenario: Public conversation retained past 14 days - -- **WHEN** a public conversation's `last_activity_at` is 15 days old -- **THEN** its content is retained until 90 days after last activity - -#### Scenario: New activity restarts the clock - -- **WHEN** a conversation receives an accepted inbound message or a finalized assistant delivery -- **THEN** `last_activity_at` advances and the retention window is measured from the new value - -### Requirement: Visibility is resolved at purge time through the root conversation - -The purge job SHALL compute each conversation's window at purge time by resolving the parent chain to the root conversation and reading its destination's current persisted visibility. No `expires_at` SHALL be stored. Descendant (subagent) conversations SHALL NOT have independent retention clocks; they purge with their root. - -#### Scenario: Visibility flip shortens retention - -- **WHEN** a channel's destination visibility changes from `public` to `private` -- **THEN** the next purge pass applies the 14-day window to conversations in that destination - -#### Scenario: Child rides the root's window - -- **WHEN** an advisor child conversation belongs to a public root conversation -- **THEN** the child's content is retained on the root's 90-day clock and deleted when the root is purged - -### Requirement: Purge deletes content wholesale and scrubs private metadata - -When a conversation expires, the purge job SHALL, in bounded work: delete all of its message rows, step rows, and descendants' content; stamp `transcript_purged_at` on the conversation row; and, for non-public conversations, null the raw-payload metadata fields (`title`, `channel_name`, actor JSON) so purged private conversations retain only safe metadata. The conversation metadata row itself SHALL survive purge. Reporting SHALL present purged content as expired, distinct from redacted. - -#### Scenario: Private conversation purged - -- **WHEN** the purge job processes an expired private conversation -- **THEN** its messages and steps are deleted, `transcript_purged_at` is set, its title/channel name/actor JSON are nulled, and the conversation still appears in listings with a generic label - -#### Scenario: Expired is distinct from redacted - -- **WHEN** the dashboard requests a purged conversation's transcript -- **THEN** the response indicates the content expired under retention, not that it was redacted for privacy - -### Requirement: Purge runs as a dedicated bounded cron - -Retention SHALL be enforced by a dedicated scheduled job (daily Vercel cron at `/api/internal/retention`), not by the heartbeat repair loop. Each run SHALL process a bounded batch ordered by `last_activity_at` and leave remaining work for later runs. A purge failure SHALL NOT affect task execution, heartbeat recovery, or delivery paths. - -#### Scenario: Bounded batch under backlog - -- **WHEN** more conversations are expired than one run's batch limit -- **THEN** the run purges up to the limit and the next run continues from the remaining backlog - -### Requirement: Single-conversation erasure uses the purge primitive - -The system SHALL expose `purgeConversation(conversationId)` deleting one conversation's content and descendants immediately, regardless of age, applying the same metadata scrubbing. - -#### Scenario: Erasure request honored - -- **WHEN** an operator invokes erasure for one conversation id -- **THEN** its messages, steps, and descendant content are deleted and its private metadata fields are scrubbed without waiting for the retention window diff --git a/packages/docs/src/content/docs/cli/upgrade.md b/packages/docs/src/content/docs/cli/upgrade.md index f18765d37..1642846b5 100644 --- a/packages/docs/src/content/docs/cli/upgrade.md +++ b/packages/docs/src/content/docs/cli/upgrade.md @@ -29,27 +29,32 @@ The command takes no extra arguments. - Move legacy `junior:conversation-work:*` Redis state into the newer conversation record and index state used by the durable worker and dashboard feed. - Backfill retained conversation records into the shared Junior SQL database. The upgrade requires `DATABASE_URL`. -- Repair legacy token and estimated-cost rollups from durable SQL agent steps in bounded batches. Conversations that are active during the repair are left unchanged and can be repaired by rerunning the command after they become idle. +- Apply the SQL schema cutover and rewrite legacy Pi-message rows into canonical conversation events. +- Repair legacy token and estimated-cost rollups from durable SQL conversation events in bounded batches. Conversations that are active during the repair are left unchanged and can be repaired by rerunning the command after they become idle. -The migrations are idempotent: rerunning them skips records that were already moved, removes stale legacy index entries that no longer have a record, and upserts SQL conversation rows. The SQL conversation backfill copies a bounded legacy slice of Redis conversation metadata; after cutover, durable conversation metadata is written to SQL while Redis remains the transcript and execution/cache store. +The migrations are idempotent: rerunning them skips records that were already moved, removes stale legacy index entries that no longer have a record, and upserts SQL conversation rows. The conversation-history import paginates through every conversation in the retained activity index; orphaned or expired Redis keys outside that index are not treated as retained history. After cutover, SQL owns durable conversation metadata and event history. -## Vercel deploys +## Hard-cutover upgrade sequence -Run `junior upgrade` from the Vercel build command when the deployment has access to the same `REDIS_URL`, `JUNIOR_STATE_KEY_PREFIX`, and `DATABASE_URL` used by production. +The canonical conversation-event cutover is not rolling-compatible. Do not run it inside a Vercel build while the previous deployment can still accept work. Use this operator sequence: -Use a build command like: +1. Block new ingress and enqueueing while leaving the previous release's workers and continuation consumers running. +2. Let existing work drain, then verify that no turns remain running or awaiting resume. +3. Stop every old worker, queue consumer, and heartbeat. Keep the old deployment stopped for the rest of the procedure. +4. Run the upgrade from an operator environment with the production `REDIS_URL`, `JUNIOR_STATE_KEY_PREFIX`, and `DATABASE_URL`. +5. Confirm the history import and visible-message seal complete with no missing rows. +6. Run `junior check`, deploy the new release, and only then reopen ingress and start the new workers. -```bash -pnpm exec junior upgrade && pnpm build -``` - -For monorepos, keep the same prefix and replace the build command with the app-specific build: +Run the upgrade as a separate operator command: ```bash -pnpm exec junior upgrade && pnpm --filter build +pnpm exec junior upgrade +pnpm exec junior check ``` -This keeps schema creation and SQL backfills out of request handlers. Runtime code trusts that the deployment ran `junior upgrade`; if schema is missing, the deployment is misconfigured and should fail clearly. +The visible-message seal fails closed if resumable work remains. After the drain succeeds, the upgrade invalidates stale resume state before it resequences conversation history while preserving reporting summaries. + +If the command exits nonzero, leave the deployment stopped, correct the reported state, and rerun it. Do not restart workers after only part of the sequence completes. ## Example output @@ -81,8 +86,9 @@ Treat that as a deploy blocker for the affected environment. Check `REDIS_URL`, After running the command: 1. Confirm the final log line includes `Junior upgrade complete`. -2. Confirm the migration summary has the expected `scanned` and `migrated` counts. -3. Run `pnpm exec junior check` before building or deploying the app. +2. Confirm `backfill-conversation-events-sql` scanned the complete retained activity index and did not stop at one page. +3. Confirm `backfill-conversation-visible-message-events` reports `missing=0`; this is the canonical-history seal. +4. Run `pnpm exec junior check` before building or deploying the app. A nonzero `missing` count for `repair-conversation-usage` means retained SQL assistant messages did not contain usable, schema-safe usage values. Junior leaves those totals unchanged. diff --git a/packages/docs/src/content/docs/start-here/deploy-to-vercel.md b/packages/docs/src/content/docs/start-here/deploy-to-vercel.md index 64aa4327d..a3154d45e 100644 --- a/packages/docs/src/content/docs/start-here/deploy-to-vercel.md +++ b/packages/docs/src/content/docs/start-here/deploy-to-vercel.md @@ -54,13 +54,15 @@ The scaffolded `package.json` includes the production build script: } ``` -New scaffolded apps run Junior upgrades before the normal build: +Keep the Vercel build command limited to snapshot preparation and the app build: ```bash -pnpm exec junior upgrade && pnpm build +pnpm build ``` -Keep that Vercel build command. Older installs should configure Junior's SQL database before enabling `junior upgrade`. `junior snapshot create` prepares sandbox runtime dependencies declared by enabled plugins before request handling starts. `junior upgrade` applies schema and state migrations before the new deployment serves traffic. +`junior snapshot create` prepares sandbox runtime dependencies declared by enabled plugins before request handling starts. Run `junior upgrade` separately from the build. For an incompatible state cutover, first block new ingress and enqueueing, let the previous deployment drain existing work, verify no resumable turns remain, and then stop its workers, queue consumers, and heartbeats before running the upgrade. A Vercel build can overlap the active deployment, so it cannot enforce that sequence. + +For an existing deployment, follow the full drain, upgrade, verification, and restart procedure in [junior upgrade](/cli/upgrade/) before promoting the new release. ## Enable Junior's Nitro deployment module diff --git a/packages/junior-dashboard/README.md b/packages/junior-dashboard/README.md index a1bbe3397..697a7fab2 100644 --- a/packages/junior-dashboard/README.md +++ b/packages/junior-dashboard/README.md @@ -11,9 +11,9 @@ state. - Better Auth owns authentication; dashboard routes fail closed when identity or required configuration is missing. - API schemas under `src/api/` define the client/server boundary. -- Reporting projections expose normalized visible messages, agent activity, - artifacts, and tool summaries rather than raw provider payloads or runtime - state. +- Conversation detail exposes one ordered, privacy-safe event log. The + dashboard owns the event-to-view reduction and never merges a duplicate + transcript, activity stream, or provider runtime state. - Private conversation access requires authenticated authorization at the server boundary. Client-side route hiding is not authorization. - The package remains stateless apart from normal auth/session infrastructure; diff --git a/packages/junior-dashboard/e2e/dashboard.spec.ts b/packages/junior-dashboard/e2e/dashboard.spec.ts index 2970997ad..579d343ab 100644 --- a/packages/junior-dashboard/e2e/dashboard.spec.ts +++ b/packages/junior-dashboard/e2e/dashboard.spec.ts @@ -163,6 +163,7 @@ test("hydrates the built dashboard client in a real browser", async ({ await expect( page.getByRole("heading", { name: "Conversations" }), ).toBeVisible(); + await page.getByRole("link", { name: /Checkout latency triage/ }).click(); await expect(page).toHaveURL( `${baseURL}/conversations/${encodeURIComponent("slack:CQA123:1770000000.000100")}`, ); @@ -315,17 +316,19 @@ test("scrolls long conversation and transcript panes independently", async ({ ...conversations[0], displayTitle: "Long transcript", generatedAt, - transcript: Array.from({ length: 60 }, (_, index) => ({ - parts: [ - { - text: `Transcript message ${index + 1} with enough content to occupy a visible row.`, - type: "text", - }, - ], - role: index % 2 === 0 ? "user" : "assistant", - timestamp: Date.parse(generatedAt) + index * 1_000, + eventHistory: { status: "available" }, + events: Array.from({ length: 60 }, (_, index) => ({ + createdAt: new Date( + Date.parse(generatedAt) + index * 1_000, + ).toISOString(), + data: { + type: "visible_message", + messageId: `message-${index + 1}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Transcript message ${index + 1} with enough content to occupy a visible row.`, + }, + seq: index, })), - transcriptAvailable: true, }, }); }); @@ -412,6 +415,7 @@ test("groups the signed-in profile and session actions in the header", async ({ }); test("inspects and copies an advisor transcript", async ({ context, page }) => { + const childConversationId = "junior:internal:dashboard-qa:advisor-plan"; await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: baseURL, }); @@ -425,24 +429,30 @@ test("inspects and copies an advisor transcript", async ({ context, page }) => { const subagentRow = page .getByRole("button", { name: "Open advisor transcript" }) .first(); - await expect(subagentRow).toHaveCSS("cursor", "pointer"); await subagentRow.click(); const drawer = page.getByRole("dialog"); - await expect(drawer.getByRole("heading", { name: "advisor" })).toBeVisible(); - await expect(drawer.getByText("Conversation ID")).toBeVisible(); + await expect( + drawer.getByRole("heading", { name: "Advisor review" }), + ).toBeVisible(); + await expect( + drawer.getByText(childConversationId, { exact: true }), + ).toBeVisible(); + await expect( + drawer.getByRole("link", { name: "Open conversation" }), + ).toHaveAttribute( + "href", + `/conversations/${encodeURIComponent(childConversationId)}`, + ); const copy = drawer.getByRole("button", { name: "Copy as Markdown" }); await expect(copy).toBeEnabled(); await copy.click(); await expect(drawer.getByRole("button", { name: "Copied" })).toBeVisible(); const markdown = await page.evaluate(() => navigator.clipboard.readText()); - expect(markdown).toContain("# advisor"); + expect(markdown).toContain("# Advisor review"); expect(markdown).toContain("Review the dashboard plan before editing."); - expect(markdown).toContain( - "Actor identity email is a reasonable profile key", - ); + expect(markdown).toContain("Review complete; no blocking issues found."); await page.setViewportSize({ height: 844, width: 390 }); await expect(drawer).toBeVisible(); - await expect(drawer.getByRole("button", { name: "Copied" })).toBeVisible(); }); diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index 38288d3db..cce7fc1d1 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -8,14 +8,12 @@ import { conversationFeedSchema, conversationParamsSchema, conversationStatsReportSchema, - conversationSubagentTranscriptReportSchema, locationDetailReportSchema, locationDirectoryReportSchema, locationParamsSchema, personParamsSchema, actorDirectoryReportSchema, actorProfileReportSchema, - subagentParamsSchema, } from "@sentry/junior/api/schema"; import { initSentry } from "@sentry/junior/instrumentation"; import type { @@ -41,7 +39,6 @@ import { readMockConversationDetail, readMockConversationFeed, readMockConversationStats, - readMockConversationSubagent, readMockLocationDetail, readMockLocationDirectory, readMockPeopleDirectory, @@ -282,8 +279,8 @@ function isAuthorized( return Boolean( session.user.emailVerified && - emailDomain && - allowedDomains.includes(emailDomain), + emailDomain && + allowedDomains.includes(emailDomain), ); } @@ -720,17 +717,6 @@ export function createDashboardApp( ? Response.json(conversationDetailReportSchema.parse(report)) : Response.json({ error: "Conversation not found." }, { status: 404 }); }); - app.get("/api/conversations/:conversationId/subagents/:subagentId", (c) => { - const { conversationId, subagentId } = subagentParamsSchema.parse( - c.req.param(), - ); - const report = conversationSubagentTranscriptReportSchema.parse( - readMockConversationSubagent(conversationId, subagentId), - ); - return report.unavailableReason === "not_found" - ? Response.json(report, { status: 404 }) - : Response.json(report); - }); } app.route("/", createJuniorApi()); app.get("/api/config", () => { diff --git a/packages/junior-dashboard/src/client/api.ts b/packages/junior-dashboard/src/client/api.ts index b751901e8..78fab379c 100644 --- a/packages/junior-dashboard/src/client/api.ts +++ b/packages/junior-dashboard/src/client/api.ts @@ -1,14 +1,12 @@ import { QueryClient, useMutation, useQuery } from "@tanstack/react-query"; import type { ZodType } from "zod"; import type { ConversationDetailReport } from "@sentry/junior/api/schema"; -import type { ConversationSubagentTranscriptReport } from "@sentry/junior/api/schema"; import type { ActorProfileReport } from "@sentry/junior/api/schema"; import type { LocationDetailReport } from "@sentry/junior/api/schema"; import { conversationDetailReportSchema, conversationFeedSchema, conversationStatsReportSchema, - conversationSubagentTranscriptReportSchema, } from "@sentry/junior/api/schema"; import { actorDirectoryReportSchema, @@ -251,32 +249,3 @@ export function readConversationData( `/api/conversations/${encodeURIComponent(conversationId)}`, ); } - -/** Fetch one child-agent transcript for the conversation detail drawer. */ -export function useConversationSubagentTranscriptData( - params: - | { - conversationId: string; - subagentId: string; - } - | undefined, -) { - return useQuery({ - enabled: Boolean(params), - queryKey: [ - "conversation-subagent", - params?.conversationId, - params?.subagentId, - ], - queryFn: async (): Promise => { - const active = params!; - return await read( - conversationSubagentTranscriptReportSchema, - `/api/conversations/${encodeURIComponent( - active.conversationId, - )}/subagents/${encodeURIComponent(active.subagentId)}`, - ); - }, - retry: false, - }); -} diff --git a/packages/junior-dashboard/src/client/components/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/components/ConversationTranscript.tsx index a0760ae31..9a8f32e15 100644 --- a/packages/junior-dashboard/src/client/components/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/components/ConversationTranscript.tsx @@ -1,9 +1,4 @@ -import { - Fragment, - useState, - type ClipboardEventHandler, - type ReactNode, -} from "react"; +import { Fragment, type ClipboardEventHandler, type ReactNode } from "react"; import { Bot, CircleAlert, @@ -12,38 +7,32 @@ import { type LucideIcon, } from "lucide-react"; -import { HighlightedCode } from "../code"; +import { countStructuredBlockChildren, HighlightedCode } from "../code"; import { detectLanguage, transcriptRoleKind, - formatBytes, formatMessageTimestamp, - formatMs, formatTranscriptDuration, actorLabel, + parseMarkdownBlocks, summarizeCost, summarizeTurns, summarizeToolCalls, summarizeUsage, - stringifyPartValue, unavailableTranscriptLabel, visualStatusForSummary, } from "../format"; import { cn } from "../styles"; -import { conversationTranscriptMessages } from "../transcriptActivity"; +import { conversationTranscriptMessages } from "../eventTranscript"; import type { ConversationTranscript, TranscriptViewMessage, - TranscriptViewPart, TranscriptViewSubagentPart, } from "../types"; -import { ExecutionSignature } from "./ExecutionSignature"; import { StatusBadge } from "./StatusBadge"; -import { ToolFrame, toolFrameClass } from "./ToolFrame"; import { TranscriptHeadingMeta, TranscriptHeadingRow, - TranscriptThoughtLabel, } from "./TranscriptHeadingRow"; import { MetricList, type MetricListItem } from "./Metric"; import { @@ -54,26 +43,21 @@ import { ToolCallsMetric, } from "./TelemetryMetrics"; import { TranscriptText } from "./TranscriptText"; -import { TranscriptThinkingView } from "./TranscriptThinkingView"; import { TranscriptSubagentView } from "./TranscriptSubagentView"; import { TranscriptContextEventView } from "./TranscriptContextEventView"; import { TranscriptToolRun } from "./TranscriptToolRun"; import { TranscriptToolView } from "./TranscriptToolView"; import { shouldCopyRawTranscript } from "./transcriptCopy"; import { - countRenderedTranscriptChildren, groupTranscriptMessages, - groupTranscriptParts, messageRawText, - type RenderedToolRunEntry, - type RenderedTranscriptPart, + type RenderedToolEntry, type TranscriptViewMode, } from "./transcriptRenderModel"; import { transcriptEmptyClass, mutedTranscriptMetaClass, } from "./transcriptStyles"; -import { previewToolValue } from "./transcriptPreview"; import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch"; type TranscriptEntry = ReturnType[number]; @@ -81,7 +65,6 @@ type TranscriptContextEntry = Extract; type TranscriptFailureEntry = Extract; type TranscriptMessageEntry = Extract; type TranscriptSubagentEntry = Extract; -type TranscriptThinkingEntry = Extract; type TranscriptToolEntry = Extract; /** Render one conversation transcript segment as actor messages and tool events. */ @@ -248,14 +231,15 @@ function SegmentEvents(props: { return (
- {props.conversation.transcriptAvailable ? ( + {props.conversation.eventHistory.status === "available" ? ( - ) : props.conversation.transcriptRedacted && messages.length > 0 ? ( + ) : props.conversation.eventHistory.status === "redacted" && + messages.length > 0 ? ( + onOpenTranscript={(part: TranscriptViewSubagentPart) => props.onOpenSubagentTranscript?.({ part, conversation: props.conversation, @@ -334,21 +318,11 @@ function VisibleTranscriptEntries(props: { /> )} - renderThinking={(entry, index) => ( - - )} renderTool={(entry, index) => ( )} /> @@ -362,7 +336,6 @@ function TranscriptEntryList(props: { renderFailure: (entry: TranscriptFailureEntry, index: number) => ReactNode; renderMessage: (entry: TranscriptMessageEntry, index: number) => ReactNode; renderSubagent: (entry: TranscriptSubagentEntry, index: number) => ReactNode; - renderThinking: (entry: TranscriptThinkingEntry, index: number) => ReactNode; renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode; }) { const search = useTranscriptSearch(); @@ -371,14 +344,11 @@ function TranscriptEntryList(props: { for (let index = 0; index < props.entries.length; ) { const entry = props.entries[index]!; - if (entry.kind === "tool" || entry.kind === "thinking") { + if (entry.kind === "tool") { const startIndex = index; - const runEntries: RenderedToolRunEntry[] = []; - while ( - props.entries[index]?.kind === "tool" || - props.entries[index]?.kind === "thinking" - ) { - runEntries.push(props.entries[index] as RenderedToolRunEntry); + const runEntries: RenderedToolEntry[] = []; + while (props.entries[index]?.kind === "tool") { + runEntries.push(props.entries[index] as RenderedToolEntry); index += 1; } const visibleEntries = search.active @@ -393,7 +363,6 @@ function TranscriptEntryList(props: { entries={visibleEntries} key={`${props.keyPrefix}:tool-run:${startIndex}`} keyPrefix={props.keyPrefix} - renderThinking={props.renderThinking} renderTool={props.renderTool} startIndex={startIndex} />, @@ -428,50 +397,35 @@ function TranscriptEntryList(props: { } function TranscriptFailureView(props: { - outcome: "error" | "aborted"; + outcome: "error" | "delivery_failed"; timestamp?: number; }) { const timestamp = formatMessageTimestamp(props.timestamp); - const isError = props.outcome === "error"; + const deliveryFailed = props.outcome === "delivery_failed"; return (