diff --git a/.gitignore b/.gitignore index b3655579621..722c0505af7 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ superset-dev-data/ .serena/ test-conflict-repo/ .amp/* + +# Claude Code session lock (runtime artifact) +.claude/scheduled_tasks.lock diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md new file mode 100644 index 00000000000..329cccc3ac9 --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md @@ -0,0 +1,327 @@ +# V2 Workspace Launch Context + +Status as of PR #3467 (branch `v2-modal-agent-launch`). See +`plans/v2-workspace-context-composition.md` for the full design. + +## What's implemented (phase 1) + +V2 "fork" workspaces now compose a full agent launch from prompt + linked +issue/PR/task metadata + attachments. Closes Gaps 4 and 5 in +`V2_WORKSPACE_MODAL_GAPS.md`; Gaps 3 and 6 remain open. + +### Pipeline (composition) + +``` +draft (modal) + → PendingWorkspaceRow + → buildForkAgentLaunch (pending page) + ├─ buildLaunchSourcesFromPending → LaunchSource[] + ├─ buildLaunchContext → LaunchContext + ├─ buildLaunchSpec → AgentLaunchSpec + └─ consumer picks chat vs terminal based on the selected agent's kind +``` + +## Dispatch architecture (pending-row-as-bus) + +Launch dispatch uses the **pending row as the transport** between the +pending page (producer) and the V2 workspace page (consumer). **Zero V1 +primitives.** Same pattern V2 preset execution uses +(`useV2PresetExecution`): live-query a record, open a pane in the V2 +`@superset/panes` store, call `workspaceTrpc.terminal.ensureSession` to +attach PTY. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Pending page │ +│ │ +│ 1. host.workspaceCreation.create → workspace exists │ +│ │ +│ 2. buildForkAgentLaunch(pending, attachments, configs) │ +│ uses the real workspaceId now that create resolved. │ +│ │ +│ 3. Dispatch per agent kind: │ +│ │ +│ kind == "terminal": │ +│ • for each attachment: workspaceTrpc.filesystem │ +│ .writeFile → /.superset/attachments/… │ +│ • pendingRow.terminalLaunch = { command, name } │ +│ │ +│ kind == "chat": │ +│ • pendingRow.chatLaunch = { │ +│ initialPrompt, initialFiles, model, taskSlug, │ +│ } │ +│ │ +│ 4. Navigate to /v2-workspace/ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ V2 workspace page mount: useConsumePendingLaunch() │ +│ │ +│ live-query pendingRow by workspaceId │ +│ │ +│ if row.terminalLaunch: │ +│ store.addTab({ panes: [{ kind:"terminal", … }] }) │ +│ TerminalPane mounts → ensureSession → write command │ +│ update(row, { terminalLaunch: null }) │ +│ │ +│ if row.chatLaunch: │ +│ store.addChatTab({ initialPrompt, initialFiles, model }) │ +│ ChatPane auto-sends on mount (existing V2 chat runtime) │ +│ update(row, { chatLaunch: null }) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Why pending-row-as-bus + +- **Durable**: pending row lives in the `pendingWorkspaces` collection. + Intent survives renderer restarts; the user can close and reopen the + app and the dispatch still fires the next time the workspace is + visited. +- **Already tied to the workspace**: `pendingRow.workspaceId` is the + natural key. No new zustand slice. +- **Producer/consumer decoupled**: pending page never touches the V2 + workspace store directly; workspace page never does the spec-build. + Each side owns its own concern. +- **Consistent with V2 preset execution** — same "stash a record, live- + query from the workspace page, open a pane" pattern is how + `useV2PresetExecution` ships preset commands. +- **Path to host-owned dispatch** (phase 5): pending page stops + populating `row.terminalLaunch`; instead passes the spec into + `host.workspaceCreation.create`. Host returns the already-running + terminal in `terminals[]`. Workspace page consumer stays — it now + reads the host-returned terminal via live query instead of the + pending row. Migration is local to the producer side; consumer never + changes. Chat stays client-driven (chat runtime is in the renderer). + +### Why not the V1 `WorkspaceInitEffects` bus + +V1's dispatcher (`WorkspaceInitEffects` → `launchAgentSession` → +`terminal-adapter`) is hard-coded to V1's `useTabsStore` in the +orchestrator's default tabs adapter. V2 workspaces render panes from a +separate `@superset/panes` store, so launches dispatched through V1 +land in a store V2 never reads — the command runs but no pane appears. +**V2 must own its launch dispatch.** + +### Files (composition, stable) + +- `shared/context/types.ts` — `LaunchSource`, `ContentPart`, `ContextSection`, `LaunchContext`, `AgentLaunchSpec`. +- `shared/context/composer.ts` — `buildLaunchContext` (parallel resolve, dedup, failure-tolerant). +- `shared/context/contributors/*` — one per source kind: `userPrompt`, `githubIssue`, `githubPr`, `internalTask`, `attachment`. +- `shared/context/buildLaunchSpec.ts` — agent-aware template rendering, inline-multimodal preservation. +- `routes/.../pending/$pendingId/buildForkAgentLaunch.ts` — pure helper that runs the composer + buildLaunchSpec from a `PendingWorkspaceRow`. + +### Files (dispatch, to be reworked per the "pending-row-as-bus" plan) + +The first wire-up attempt shipped through V1's `useWorkspaceInitStore` + +`WorkspaceInitEffects`. That path is being ripped out because V1's +orchestrator uses V1's `useTabsStore`, which V2 doesn't render from. + +- `shared/context/buildAgentLaunchRequest.ts` — **deprecated once dispatch migrates.** Still useful as a reference for the V1 shape if we ever need it; otherwise removable after the pending-row-as-bus rewrite. +- `renderer/hooks/useEnqueueAgentLaunch/*` — **to be removed.** V1-bus primitive. +- `routes/.../pending/$pendingId/page.tsx` (the `enqueueAgentLaunch` call) — **to be replaced** by the kind-split described under "Dispatch architecture" above. + +### Files (dispatch, to be added) + +- `pendingWorkspaceSchema` in `providers/.../schema.ts` — gain `terminalLaunch?` and `chatLaunch?` optional fields. +- `routes/.../v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/*` — mount-effect hook that live-queries the pending row, opens a pane via V2 `@superset/panes` store, writes the command via `workspaceTrpc`, clears the field. + +### Agent templates + +Both system and user templates are Mustache-rendered via +`renderPromptTemplate`. Variables: `{{userPrompt}}`, `{{tasks}}`, +`{{issues}}`, `{{prs}}`, `{{attachments}}`. System default is empty +(harnesses discover their own `AGENTS.md` / `CLAUDE.md`). User default +is markdown with the pre-rendered kind-blocks dropped in order. Users +can override per-agent in settings. + +## Test plan + +### Local manual smoke + +1. `bun dev`, open the desktop app. +2. Create a V2 project if needed, ensure Claude (or another terminal + agent) is enabled in Settings → Agents. +3. Open the V2 new-workspace modal (dashboard). + +#### Scenarios + +- [ ] **Prompt only**. Type "add a README". Submit. Workspace opens; Claude's terminal receives the prompt as an argv. +- [ ] **Prompt + attachment**. Drop a small text file. Submit. File lands at `/.superset/attachments/`; prompt includes `- .superset/attachments/`. +- [ ] **Prompt + linked GitHub issue**. Link an issue via `@` mention. Submit. Prompt includes `# `. (Body is empty — see known gaps.) +- [ ] **Prompt + linked task**. Link an internal task. Submit. Prompt includes `# Task `; `taskSlug` in launch request matches task slug. +- [ ] **Prompt + linked PR**. Link a PR. Submit. Prompt includes `# <PR title>`. +- [ ] **Multiple sources** (prompt + task + issue + PR + attachment). Submit. All sections appear in the prompt in order. `taskSlug` = first internal-task slug. +- [ ] **Retry on failure**. Disable network, submit, fail; re-enable, hit retry button. Second attempt re-enqueues correctly (no stale setup lingers). + +### Automated + +- `bun test apps/desktop/src/shared/context/ apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/ apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/\$pendingId/` — **113 tests**, including composer dedup/ordering/failure, contributor 404-null semantics, Claude/codex snapshot rendering, bridge base64 encoding + filename dedup, pending-page source mapping, and the V1 fallback path. +- `bunx tsc --noEmit -p apps/desktop/tsconfig.json` — clean in the new surface area. + +### Demo script + +`apps/desktop/scripts/demo-launch-spec.ts` renders `AgentLaunchSpec` +across scenarios for any built-in agent. Run: +```bash +bun run scripts/demo-launch-spec.ts # claude + codex + cursor-agent +bun run scripts/demo-launch-spec.ts claude # just claude +``` + +## Known phase 1 gaps + +- **Issue / PR / task bodies are not injected.** Host-service has no + `getIssueContent` / `getPullRequestContent` / `getInternalTaskContent` + endpoint yet, and the renderer refuses to fall back to the existing + Electron procedure (we don't want Electron IPC in V2). The resolver + stubs return empty bodies; agents see title + URL + task-slug only. +- **No agent picker in the V2 modal.** `getFallbackAgentId` chooses + (prefers Claude, falls back to first enabled). Settings-level + overrides are respected. +- **Remote hosts** (`hostTarget.kind === "remote"`) — launch enqueue + still runs client-side via `useWorkspaceInitStore`. Remote terminals + are out of scope for phase 1; no regression because V2 doesn't + support remote agent launch today. +- **Base64 round-trip on attachments.** IndexedDB store → data URL → + `Uint8Array` (V2 pipeline) → base64 data URL (V1 wire). Functional + but wasteful; bytes-over-IPC is a later optimization. +- **No host-service-side launch.** Phase 1 launches via V1 renderer + adapters. For remote host support, host-service needs its own + `executeAgentLaunch` mirror. + +## Known footguns to revisit (post-testing cleanup) + +Caught during manual testing, not currently biting us, but worth +fixing before the dispatch rewrite is considered done: + +1. **Deep solve for binary transport.** Current fix for the + `PromptInput` blob-URL revoke race (commit 33730ff01) honors the + library's contract — uses the `message.files` passed into + `onSubmit` (already converted to data URLs) instead of re-reading + provider state. Works correctly but still transports bytes as + base64 strings across layers. The deep solve is to flow `File` / + `Blob` objects end-to-end; URLs stay pure UI preview concerns. + Library-level change to `@superset/ui/ai-elements/prompt-input` + (`FileUIPart & { file: File }` through the provider) + downstream + `ChatLaunchConfig.initialFiles: { file: Blob, ... }[]` + bytes + branch for `workspaceTrpc.filesystem.writeFile`. Touches V1, V2, + chat, and every consumer — deliberate staged PR, not a quick fix. + +2. **Reload-mid-launch spawns a second PTY.** `consumeTerminalLaunch` + calls `crypto.randomUUID()` for `terminalId` each time it fires. If + the user reloads the app between `terminalLaunch` being applied to + the pending row and the consume clearing it, the fresh consume + generates a new terminalId and calls `ensureSession` again — first + PTY orphaned, second one created. Fix: store the `terminalId` on + `PendingTerminalLaunch` itself (generate once in `dispatchForkLaunch`); + `ensureSession` becomes idempotent on repeat consumes. + +3. **Silent failure in the consume hook.** `ensureSession` / + `addTab` failures `console.warn` and return — user sees no pane + open and no error UI. Wrap in try/toast with the error message. + Low urgency while `[v2-launch]` debug logs are present; becomes + visible when those are removed. + +4. **`joinPath` assumes POSIX separators.** Fine on Mac/Linux hosts + where the worktree paths come from. When remote-host launch lands + (phase 5) and we get Windows hosts, this breaks. Swap for a + proper cross-platform join (or just use `path-browserify`). + +5. **Schema coupling between old and new IDB stores.** Dexie opened + the hand-rolled store's existing DB (`superset-pending-attachments`, + version 1) transparently. Any future schema change (indices, + migration) requires bumping the Dexie version and writing a + migration step. + +6. **`PendingTerminalLaunch.attachmentNames` is populated but never + read by the consume hook.** Currently informational. Either drop + the field, or use it for a UI "files attached" hint in the + workspace-creation success toast. + +7. **Remove the `[v2-launch]` debug logs** from `dispatchForkLaunch`, + `useConsumePendingLaunch`, and `useSubmitWorkspace` once the + end-to-end flow is stable. Replace with a single structured + `captureEvent` call at the pane-opened milestone. + +## Follow-ups (roughly in priority order) + +0. **Rewrite dispatch to pending-row-as-bus** (blocking phase-1 ship — + current V1-bus dispatch is broken for V2). See "Dispatch architecture" + above. Mirrors `useV2PresetExecution`. Estimated 3-4 hours: + - Schema: `terminalLaunch?` + `chatLaunch?` on `pendingWorkspaceSchema`. + - Producer: pending page populates one of those fields after `create` + resolves, writes attachments via `workspaceTrpc.filesystem`. + - Consumer: new `useConsumePendingLaunch(workspaceId, store)` mount + effect on the V2 workspace page. Opens pane in V2 store, writes + command via `workspaceTrpc.terminal`, clears the field. + - Rip out: `useEnqueueAgentLaunch` + its call. `buildAgentLaunchRequest` + stays for now as a reference but is no longer imported. +1. **Host-service body endpoints** (`getIssueContent` / + `getPullRequestContent` / `getInternalTaskContent`). Swap the + resolver stubs in `buildForkAgentLaunch.ts` → contributors emit real + body markdown → agents see full context. Unblocks full Gap 4. +2. **Gap 3: AI branch name generation.** `workspaces.generateBranchName` + call before submit; 30s timeout; fallback to slug preview. +3. **Gap 6: create-from-PR flow.** Detect `github-pr` source and route + to a different host-service mutation that creates the workspace from + the PR's head branch. Today the PR is treated as context only. +4. **V2 modal agent picker.** Minimum: a display pill showing the + default agent with a click-through to settings. Full: a picker + inline in the modal matching V1's UX. +5. **Bytes transport.** IndexedDB stores `Blob`; pipeline passes + `Uint8Array` over IPC via SuperJSON; adapters gain + `filesystem.writeFile({kind:"bytes"})`. Eliminates the base64 + round-trip. +6. **Anthropic Files API** for chat agents only. Upload once, reference + by file ID across launches. Smaller payloads, server-side caching. + Requires chat-runtime changes; does not apply to CLI agents. +7. **Remote host launch.** Host-service-side `executeAgentLaunch` so + workspaces on remote hosts can launch agents without renderer + involvement. Unblocks remote-first workflows. +8. **Per-kind XML wrapping for Claude** (optional). Extend + `renderPromptTemplate` with Mustache-style conditional sections + (`{{#issues}}...{{/issues}}`) and ship a Claude-XML default that + wraps non-empty blocks in tags. Currently defaults are plain + markdown; users can override in settings. + +## File layout reference + +``` +apps/desktop/src/ + shared/context/ + types.ts + composer.ts composer.integration.test.ts + composer.test.ts + buildLaunchSpec.ts buildLaunchSpec.test.ts + buildAgentLaunchRequest.ts buildAgentLaunchRequest.test.ts + __fixtures__/ + attachment.logs-txt.ts + githubIssue.auth-middleware.ts + githubPr.auth-rewrite.ts + internalTask.refactor-auth.ts + launchContext.multi-source.ts + launchContext.prompt-only.ts + index.ts + contributors/ + userPrompt.ts userPrompt.test.ts + attachment.ts attachment.test.ts + githubIssue.ts githubIssue.test.ts + githubPr.ts githubPr.test.ts + internalTask.ts internalTask.test.ts + index.ts + renderer/hooks/useEnqueueAgentLaunch/ + useEnqueueAgentLaunch.ts useEnqueueAgentLaunch.test.ts + index.ts + renderer/routes/_authenticated/_dashboard/pending/$pendingId/ + page.tsx (wires enqueue) + buildForkAgentLaunch.ts buildForkAgentLaunch.test.ts + +packages/shared/src/ + agent-definition.ts (contextPromptTemplateSystem/User fields) + agent-catalog.ts (builtin chat agent defaults) + agent-prompt-template.ts (renderPromptTemplate + context vars + defaults) + builtin-terminal-agents.ts (builtin terminal agent defaults) + +packages/local-db/src/schema/ + zod.ts (contextPromptTemplate* in preset + custom schemas) +``` diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md new file mode 100644 index 00000000000..dda5cc097e2 --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md @@ -0,0 +1,202 @@ +# V2 Launch Context — Body-Fetching Gaps + +Companion to `V2_LAUNCH_CONTEXT.md`. Tracks remaining work to make +linked issues / PRs / tasks useful to the agent. + +## Current state (2026-04-15) + +Claude receives titles only — no bodies: + +``` +<user prompt> + +# <task title> + +# <issue title> + +# PR #<n> — <pr title> +Branch `<branch>` is checked out in this workspace — commits you make continue this PR. + +# Attached files +... +- .superset/attachments/<file> +``` + +Bodies are empty because `buildResolveCtxFromPending` stubs return +empty strings. The pipeline otherwise works end-to-end. + +## Design decisions (locked) + +1. **Inline in prompt.** Bodies go directly into the prompt via + `{{issues}}` / `{{prs}}` / `{{tasks}}` template variables. No file + writes for linked context. Only user-uploaded attachments write to + `.superset/attachments/`. +2. **PR checkout is true.** The fork-from-PR flow checks out the PR's + head branch. Prompt says so. +3. **No body truncation** (or very high cap, e.g. 200 KB/source). Modern + context windows are large. Don't cap aggressively. +4. **No sanitization.** Prompt goes into a heredoc with a random + delimiter (no shell injection). Agent reads raw text, no HTML parser + downstream. V1's entity escaping was unnecessary. +5. **Attachments framing.** The `{{attachments}}` block includes a short + header cueing the agent to read the files. Just paths; agent handles + the rest. +6. **Issue/PR comments.** Defer. Note in the follow-ups. +7. **Per-agent framing.** Don't over-engineer. Give the path; agent + figures it out. + +## Work plan + +### 1. Host-service `getIssueContent` + +Add to `workspaceCreation` router (same GitHub auth path as +`searchGitHubIssues`): + +```ts +getIssueContent: protectedProcedure + .input(z.object({ projectId: z.string(), issueNumber: z.number() })) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + const octokit = await ctx.github(); + const { data } = await octokit.issues.get({ + owner: repo.owner, repo: repo.name, issue_number: input.issueNumber, + }); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.html_url, + state: data.state, + author: data.user?.login ?? null, + }; + }), +``` + +### 2. Host-service `getPullRequestContent` + +Same router, wraps `octokit.pulls.get`: + +```ts +getPullRequestContent: protectedProcedure + .input(z.object({ projectId: z.string(), prNumber: z.number() })) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + const octokit = await ctx.github(); + const { data } = await octokit.pulls.get({ + owner: repo.owner, repo: repo.name, pull_number: input.prNumber, + }); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.html_url, + state: data.state, + branch: data.head.ref, + baseBranch: data.base.ref, + author: data.user?.login ?? null, + }; + }), +``` + +### 3. Internal-task body source + +Find the API for task details. V1 uses Electron IPC; V2 has +collections in the task view (live-query from cloud). Options: + +- `apiTrpcClient.tasks.get.query({ id })` if such a procedure exists. +- Read from the existing `collections.tasks` live-query data (already + in renderer memory from the task view). +- Host-service proxies the Superset API. + +Need to inspect the task view's data source to find the right shape. +The pending row already has `{ id, slug, title }` from the picker; +the missing field is `description` (and potentially +`acceptanceCriteria`, `comments`, `labels`). + +### 4. Swap stubs in `buildResolveCtxFromPending` + +`apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts` + +Replace the three fake fetchers in `buildResolveCtxFromPending` with +real calls to host-service via `getHostServiceClientByUrl(hostUrl)`: + +```ts +fetchIssue: async (url) => { + const match = pending.linkedIssues.find(i => i.url === url); + if (!match?.number) throw notFound(url); + const data = await client.workspaceCreation.getIssueContent.query({ + projectId: pending.projectId, + issueNumber: match.number, + }); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + slug: match.slug, + }; +}, +``` + +Same pattern for PR (using `match.prNumber`) and task (using task API). + +### 5. Pass `hostUrl` to `buildForkAgentLaunch` + +Currently the function doesn't have the host-service client. Thread +`hostUrl` (or the client itself) through `BuildForkAgentLaunchInputs` +so the resolvers can make real calls. + +## Target prompt (after fixes) + +``` +<user prompt> + +# Task TASK-42 — Refactor auth middleware + +Split session-token storage from request handling so we can encrypt +at rest. Keep the public API shape stable. + +Acceptance criteria: +- Sessions encrypted at rest +- No public-API shape change +- Migration for existing sessions + +# Issue #123 — Auth middleware stores tokens in plaintext + +Legal flagged this. Sessions written to disk without encryption. We +need to move to an encrypted KV before the compliance deadline. + +The token-issuance path sets kid=k_primary but the active signing +key rotated to k_2026q1 last quarter. Decrypt falls back to +legacy plaintext which is the compliance violation... + +# PR #200 — Rewrite auth middleware + +Branch `fix/auth-encryption` is checked out in this workspace — +commits you make continue this PR. + +Replaces plaintext token storage with encrypted KV. Migrates +existing sessions on first request... + +# Attached files + +The user attached these files alongside the prompt. They've been +written into the worktree at `.superset/attachments/`. Read them +to understand the request. + +- .superset/attachments/trace.log +- .superset/attachments/notes.md +``` + +## Sequence + +1. `getIssueContent` host-service procedure + stub swap → issue bodies flow. +2. `getPullRequestContent` procedure + stub swap → PR bodies + branch. +3. Task body source (scope the API first). +4. Thread `hostUrl` into `buildForkAgentLaunch` inputs. + +## Deferred + +- Issue/PR comments (phase 2). +- Body truncation (revisit if agents hit context limits in practice). +- Attach-as-file mode (not needed; inline works). diff --git a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md new file mode 100644 index 00000000000..a54e7eea048 --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md @@ -0,0 +1,144 @@ +# V2 Launch Dispatch — Test Plan + +Checklist for verifying the V2 workspace launch pipeline end-to-end. +Pair with `V2_LAUNCH_CONTEXT.md` for architectural background and +`v2-launch-test-artifacts/` for copy-pasteable sample data. + +## Setup + +1. `bun dev` at the repo root. +2. Ensure your active org has V2 cloud enabled (or you're testing a V2 + project). +3. Settings → Agents: confirm **Claude** is enabled. For chat-agent + tests, enable **Superset Chat**. +4. (Optional) Open devtools console and filter by `[v2-launch]` to trace + dispatch. `collections` is exposed globally for pending-row inspection: + ```js + collections.pendingWorkspaces.toArray() + ``` + +## A. Happy-path — terminal agent (Claude) + +- [ ] **A1. Prompt only** — "add a README explaining this repo." Claude pane + opens. The command includes the prompt. No errors. +- [ ] **A2. Prompt + text attachment** — drag `v2-launch-test-artifacts/trace.log` + into the modal. After launch: verify `.superset/attachments/trace.log` + exists in the worktree (terminal: `ls .superset/attachments/`). + Claude prompt contains `![trace.log](.superset/attachments/trace.log)`. +- [ ] **A3. Prompt + image** — drag `v2-launch-test-artifacts/sample.png`. + Same as A2 with the image. +- [ ] **A4. Duplicate filename** — drag `trace.log` twice. Both files exist; + second is named `trace_1.log`. Prompt references both. +- [ ] **A5. Prompt + linked GitHub issue** — paste a real issue URL from the + picker. Claude prompt contains `# <issue title>`. +- [ ] **A6. Prompt + linked PR** — paste a PR URL. Prompt contains + `# <PR title>` and `Branch: \`<branch>\``. +- [ ] **A7. Prompt + internal task** — link a task from the picker. + Prompt contains `# Task <id> — <title>`. +- [ ] **A8. Multi-source** — prompt + task + 2 issues + PR + 2 attachments. + All appear in the prompt, ordered: + user-prompt → tasks → issues → prs → attachment list. +- [ ] **A9. Rich-editor multimodal** — if the editor supports inline image + drops, drop an image between two text chunks. Image ref sits inline, + not appended at the end. + +## B. Happy-path — chat agent (Superset Chat) + +Disable Claude (or set Superset Chat as preferred via order in settings). + +- [ ] **B1. Prompt only** — chat pane opens; first user message = prompt; + agent response streams. +- [ ] **B2. Prompt + attachment** — first message carries the file + (visible in the message bubble). +- [ ] **B3. Prompt + linked issue** — first message contains the issue + title block. +- [ ] **B4. Retry on send failure** — block network before submit, wait + for V2 chat retry loop, unblock. Message eventually sends. + `pending.chatLaunch` only clears after success. + +## C. Pending-row lifecycle + +- [ ] **C1. Field clears after consume** — devtools console after launch: + ```js + collections.pendingWorkspaces.toArray() + .find(r => r.workspaceId === '<WS-ID>') + ``` + `terminalLaunch` / `chatLaunch` are `null`. +- [ ] **C2. No re-fire on revisit** — navigate out and back to the + workspace. No duplicate pane. +- [ ] **C3. Crash-safe** — submit, quit app before workspace opens. + Reopen app, navigate to `/v2-workspace/<ID>`. Pane still opens. + Pending row cleared after. +- [ ] **C4. Concurrent creates** — submit two workspaces in rapid + succession (different projects). Both pending rows dispatch + independently; no cross-contamination. + +## D. Failure paths + +- [ ] **D1. create fails** — kill host-service, submit. Pending page shows + "failed" with retry. No launch stashed. Retry after restart works. +- [ ] **D2. Attachment write fails** — manually `chmod` the worktree + read-only, submit with attachments. Dispatch logs warning; pane + still opens; files missing (expected degradation). +- [ ] **D3. `ensureSession` fails** — stop host-service after create but + before navigation. Consume hook logs warning. `terminalLaunch` + stays set. Restart host-service, refresh. Consume re-fires. +- [ ] **D4. Agent disabled mid-flow** — enable agent, start submit, disable + before create completes. Pending page finishes. No pane opens. + Pending row `terminalLaunch` stays null. +- [ ] **D5. No enabled agents** — disable all agents in settings. Submit. + Workspace creates. No pane opens. Expected. + +## E. Source-mapping edge cases + +- [ ] **E1. Empty prompt, attachments only** — submit with only a file, + no text. Terminal opens with the no-prompt command + (`claude --dangerously-skip-permissions`). +- [ ] **E2. Whitespace-only prompt** — `" \n "`. Treated as empty. +- [ ] **E3. Multiple linked issues** — 2+ github issues. Both render in + order. +- [ ] **E4. Task + issue together** — `taskSlug` = task's slug (task + wins). Both bodies render. +- [ ] **E5. Duplicate issue URL** — link same issue twice. Deduped. +- [ ] **E6. PR only** — no prompt, no issues, just a linked PR. Launch + succeeds; prompt = PR block. + +## F. Custom / non-default agents + +- [ ] **F1. Codex (terminal)** — disable Claude, enable Codex. Submit. + Codex pane runs prompt. +- [ ] **F2. Custom terminal agent** — create one in settings with command + `echo` (simple test). Submit. Pane runs `echo <prompt>`. +- [ ] **F3. Custom `contextPromptTemplateUser`** — settings → Claude → + override user template to `"PREFIX {{userPrompt}} SUFFIX"`. Submit. + Command contains `PREFIX <prompt> SUFFIX`. + +## G. Cross-pane behavior + +- [ ] **G1. Setup script + agent** — project has a setup script, submit. + Setup script pane **and** agent pane both appear as separate panes. + (This was the V1-bus bug that triggered the rewrite — if agent + appears but setup script merges into same pane, regression.) +- [ ] **G2. Presets + agent** — configure a default preset that + auto-applies. Submit. Preset terminals + agent terminal all + coexist. +- [ ] **G3. Chat + terminal presets** — chat agent + preset terminals. + Both appear. + +## H. V1 regression + +- [ ] **H1. V1 workspace creation still works** — create via the V1 + modal (old workspace view, not V2 dashboard). V1 dispatch via + `WorkspaceInitEffects` + `useTabsStore` unchanged. Agent runs + as before. + +## Priority + +If time-limited, run these first: + +- A1, A2 — minimum happy path terminal +- A8 — multi-source terminal +- B1 — minimum happy path chat +- C1 — field clears +- G1 — setup-script regression +- H1 — V1 regression diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5f4c1393033..3775aa6b8b7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -163,6 +163,7 @@ "date-fns": "^4.1.0", "default-shell": "^2.2.0", "detect-libc": "2.0.4", + "dexie": "^4.4.2", "diff": "^7.0.0", "dnd-core": "^16.0.1", "dockerfile-ast": "0.7.1", @@ -186,7 +187,6 @@ "highlight.js": "^11.11.1", "html-to-image": "^1.11.13", "http-proxy": "^1.18.1", - "idb": "^8.0.3", "idb-keyval": "^6.2.2", "jose": "^6.1.3", "js-yaml": "^4.1.1", diff --git a/apps/desktop/scripts/demo-launch-spec.ts b/apps/desktop/scripts/demo-launch-spec.ts new file mode 100644 index 00000000000..87ddf277c50 --- /dev/null +++ b/apps/desktop/scripts/demo-launch-spec.ts @@ -0,0 +1,248 @@ +/** + * Demo: show what buildLaunchContext + buildLaunchSpec produce for various + * canonical inputs, across all built-in agents. + * + * Not a test — manual eyeball tool for template iteration before the V2 + * modal wire-up lands (step 9). + * + * Run: bun run scripts/demo-launch-spec.ts + * or: bun run scripts/demo-launch-spec.ts claude + * or: bun run scripts/demo-launch-spec.ts codex cursor-agent + */ + +import { buildLaunchSpec } from "../src/shared/context/buildLaunchSpec"; +import { buildLaunchContext } from "../src/shared/context/composer"; +import { defaultContributorRegistry } from "../src/shared/context/contributors"; +import type { LaunchSource, ResolveCtx } from "../src/shared/context/types"; +import { + indexResolvedAgentConfigs, + resolveAgentConfigs, +} from "../src/shared/utils/agent-settings"; + +// --------------------------------------------------------------------------- +// Stub resolvers (mirror what host-service/issues + task services would return) +// --------------------------------------------------------------------------- + +const resolveCtx: ResolveCtx = { + projectId: "demo-project", + signal: new AbortController().signal, + fetchIssue: async (url) => ({ + number: 123, + url, + title: "Auth middleware stores tokens in plaintext", + body: "Legal flagged this last week. Sessions written to disk without encryption. We need to move to an encrypted KV before the compliance deadline.", + slug: "auth-middleware-stores-tokens-in-plaintext", + }), + fetchPullRequest: async (url) => ({ + number: 200, + url, + title: "Rewrite auth middleware", + body: "Replaces plaintext token storage with encrypted KV. Migrates existing sessions on first request.", + branch: "fix/auth-encryption", + }), + fetchInternalTask: async (id) => ({ + id, + slug: "refactor-auth", + title: "Refactor auth middleware", + description: + "Split session-token storage from request handling so we can encrypt at rest. Keep the public API shape stable.", + }), +}; + +// --------------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------------- + +interface Scenario { + name: string; + sources: LaunchSource[]; +} + +const SCENARIOS: Scenario[] = [ + { + name: "plain prompt", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "add e2e tests for the checkout flow" }, + ], + }, + ], + }, + { + name: "prompt + linked issue", + sources: [ + { + kind: "user-prompt", + content: [{ type: "text", text: "fix this" }], + }, + { + kind: "github-issue", + url: "https://github.com/acme/repo/issues/123", + }, + ], + }, + { + name: "inline text + image + text (rich editor)", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "look at this:" }, + { + type: "image", + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + }, + { type: "text", text: "<- heres more text" }, + ], + }, + ], + }, + { + name: "inline + issue (editor image between text with linked issue)", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "look at this:" }, + { + type: "image", + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + }, + { type: "text", text: "<- heres more text" }, + ], + }, + { kind: "github-issue", url: "https://github.com/acme/repo/issues/123" }, + ], + }, + { + name: "prompt + task + issue + PR + attachment", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "refactor the auth middleware end-to-end" }, + ], + }, + { kind: "internal-task", id: "TASK-42" }, + { + kind: "github-issue", + url: "https://github.com/acme/repo/issues/123", + }, + { + kind: "github-pr", + url: "https://github.com/acme/repo/pull/200", + }, + { + kind: "attachment", + file: { + data: new TextEncoder().encode( + "2026-04-14 ERROR auth.ts:42 token decrypt failed\n", + ), + mediaType: "text/plain", + filename: "logs.txt", + }, + }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +const requestedAgentsArg = process.argv.slice(2); +const configs = indexResolvedAgentConfigs(resolveAgentConfigs({})); +const requestedAgents = + requestedAgentsArg.length > 0 + ? requestedAgentsArg + : ["claude", "codex", "cursor-agent"]; + +function divider(char = "=", n = 72): string { + return char.repeat(n); +} + +function indent(text: string, prefix = " "): string { + return text + .split("\n") + .map((line) => prefix + line) + .join("\n"); +} + +for (const scenario of SCENARIOS) { + console.log(`\n${divider("=")}`); + console.log(`SCENARIO: ${scenario.name}`); + console.log(divider("=")); + + const ctx = await buildLaunchContext( + { + projectId: "demo-project", + sources: scenario.sources, + agent: { id: "claude" }, + }, + { contributors: defaultContributorRegistry, resolveCtx }, + ); + + if (ctx.failures.length > 0) { + console.log("FAILURES:"); + for (const f of ctx.failures) console.log(` - ${f.error}`); + } + + for (const agentId of requestedAgents) { + const config = configs.get(agentId as never); + if (!config) { + console.log(`\n[skip] ${agentId} — not a known agent`); + continue; + } + + const spec = buildLaunchSpec({ ...ctx, agent: { id: config.id } }, config); + console.log(`\n${divider("-")}`); + console.log(`AGENT: ${config.label} (${config.id})`); + console.log(divider("-")); + + if (!spec) { + console.log("(null — no agent)"); + continue; + } + + console.log(`taskSlug: ${spec.taskSlug ?? "(none)"}`); + console.log(`system parts: ${spec.system.length}`); + console.log(`user parts: ${spec.user.length}`); + console.log( + `attachments: ${spec.attachments.length} (${ + spec.attachments.map((p) => p.type).join(", ") || "none" + })`, + ); + + if (spec.system.length > 0) { + console.log("\n[SYSTEM]"); + for (const part of spec.system) { + if (part.type === "text") console.log(indent(part.text)); + } + } + + if (spec.user.length > 0) { + console.log("\n[USER]"); + for (const part of spec.user) { + if (part.type === "text") { + console.log(indent(part.text)); + } else if (part.type === "image") { + console.log( + indent(`<image: ${part.mediaType}, ${part.data.length} bytes>`), + ); + } else if (part.type === "file") { + console.log( + indent( + `<file: ${part.filename ?? "(unnamed)"}, ${part.mediaType}, ${part.data.length} bytes>`, + ), + ); + } + } + } + } +} + +console.log("\n"); diff --git a/apps/desktop/src/renderer/lib/pending-attachment-store.ts b/apps/desktop/src/renderer/lib/pending-attachment-store.ts index aeb450811f9..24473cdad3c 100644 --- a/apps/desktop/src/renderer/lib/pending-attachment-store.ts +++ b/apps/desktop/src/renderer/lib/pending-attachment-store.ts @@ -1,34 +1,36 @@ +import Dexie, { type Table } from "dexie"; + /** - * IndexedDB store for pending workspace attachment blobs. - * Blobs are keyed by `${pendingId}/${index}` and stored raw (no compression). + * IndexedDB store for pending workspace attachment blobs. Keyed by + * `${pendingId}/${uuid}` so we can prefix-query all blobs belonging + * to a single pending row on retry or cleanup. + * + * Dexie handles transaction lifecycle — no manual tx.complete waits, + * no "transaction has finished" footguns. */ -const DB_NAME = "superset-pending-attachments"; -const STORE_NAME = "blobs"; -const DB_VERSION = 1; - interface StoredAttachment { + key: string; // pendingId/uuid blob: Blob; mediaType: string; filename: string; } -function openDb(): Promise<IDBDatabase> { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); +class PendingAttachmentsDb extends Dexie { + attachments!: Table<StoredAttachment, string>; + + constructor() { + super("superset-pending-attachments"); + this.version(1).stores({ + attachments: "&key", // primary key only + }); + } } +const db = new PendingAttachmentsDb(); + /** - * Store attachment blobs from the PromptInput into IndexedDB. + * Store attachment blobs from the PromptInput. * Call before closing the modal so blobs survive for retry. */ export async function storeAttachments( @@ -37,28 +39,25 @@ export async function storeAttachments( ): Promise<void> { if (files.length === 0) return; - const db = await openDb(); - const store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME); - - await Promise.all( + const resolved = await Promise.all( files.map(async (file) => { - const blobId = crypto.randomUUID(); const response = await fetch(file.url); + if (!response.ok) { + throw new Error( + `Failed to fetch attachment: ${response.status} ${response.statusText}`, + ); + } const blob = await response.blob(); - const value: StoredAttachment = { + return { + key: `${pendingId}/${crypto.randomUUID()}`, blob, mediaType: file.mediaType, filename: file.filename ?? "attachment", - }; - return new Promise<void>((resolve, reject) => { - const request = store.put(value, `${pendingId}/${blobId}`); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); + } satisfies StoredAttachment; }), ); - db.close(); + await db.attachments.bulkPut(resolved); } /** @@ -68,28 +67,11 @@ export async function storeAttachments( export async function loadAttachments( pendingId: string, ): Promise<Array<{ data: string; mediaType: string; filename: string }>> { - const db = await openDb(); - const store = db.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME); - - const entries: StoredAttachment[] = await new Promise((resolve, reject) => { - const prefix = `${pendingId}/`; - const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`); - const request = store.openCursor(range); - const results: StoredAttachment[] = []; - - request.onsuccess = () => { - const cursor = request.result; - if (!cursor) { - resolve(results); - return; - } - results.push(cursor.value as StoredAttachment); - cursor.continue(); - }; - request.onerror = () => reject(request.error); - }); - - db.close(); + const prefix = `${pendingId}/`; + const entries = await db.attachments + .where("key") + .startsWith(prefix) + .toArray(); return Promise.all( entries.map(async (entry) => ({ @@ -105,34 +87,15 @@ export async function loadAttachments( * Call on create success or dismiss. */ export async function clearAttachments(pendingId: string): Promise<void> { - const db = await openDb(); - const store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME); - - await new Promise<void>((resolve, reject) => { - const prefix = `${pendingId}/`; - const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`); - const request = store.openCursor(range); - - request.onsuccess = () => { - const cursor = request.result; - if (!cursor) { - resolve(); - return; - } - cursor.delete(); - cursor.continue(); - }; - request.onerror = () => reject(request.error); - }); - - db.close(); + const prefix = `${pendingId}/`; + await db.attachments.where("key").startsWith(prefix).delete(); } function blobToDataUrl(blob: Blob): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); - reader.onerror = () => reject(new Error("Failed to read blob")); + reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts new file mode 100644 index 00000000000..5b57cc209ab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "bun:test"; +import { resolveAgentConfigs } from "shared/utils/agent-settings"; +import { + buildForkAgentLaunch, + buildLaunchSourcesFromPending, +} from "./buildForkAgentLaunch"; + +const PROJECT_ID = "proj-1"; + +function pendingBase( + overrides: Partial<Parameters<typeof buildLaunchSourcesFromPending>[0]> = {}, +): Parameters<typeof buildLaunchSourcesFromPending>[0] { + return { + projectId: PROJECT_ID, + prompt: "", + linkedIssues: [], + linkedPR: null, + ...overrides, + }; +} + +describe("buildLaunchSourcesFromPending", () => { + test("returns [] when everything is empty", () => { + expect(buildLaunchSourcesFromPending(pendingBase(), undefined)).toEqual([]); + }); + + test("produces user-prompt source when prompt is non-empty", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ prompt: "refactor auth" }), + undefined, + ); + expect(sources).toEqual([ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor auth" }], + }, + ]); + }); + + test("trims whitespace-only prompts out", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ prompt: " \n " }), + undefined, + ); + expect(sources.filter((s) => s.kind === "user-prompt")).toEqual([]); + }); + + test("orders sources: user-prompt, task, issue, pr, attachment", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ + prompt: "fix", + linkedIssues: [ + { source: "internal", taskId: "T-1", slug: "s", title: "t" }, + { + source: "github", + url: "https://x/issues/9", + number: 9, + slug: "s", + title: "t", + state: "open", + }, + ], + linkedPR: { + prNumber: 1, + url: "https://x/pull/1", + title: "t", + state: "open", + }, + }), + [ + { + data: "data:text/plain;base64,AA==", + mediaType: "text/plain", + filename: "a.txt", + }, + ], + ); + expect(sources.map((s) => s.kind)).toEqual([ + "user-prompt", + "internal-task", + "github-issue", + "github-pr", + "attachment", + ]); + }); + + test("decodes base64 data URLs to Uint8Array", () => { + const sources = buildLaunchSourcesFromPending(pendingBase(), [ + { + data: "data:text/plain;base64,AQID", + mediaType: "text/plain", + filename: "logs.txt", + }, + ]); + expect(sources).toHaveLength(1); + const source = sources[0]; + if (source?.kind !== "attachment") throw new Error("wrong kind"); + expect(source.file.filename).toBe("logs.txt"); + expect(Array.from(source.file.data)).toEqual([1, 2, 3]); + }); +}); + +describe("buildForkAgentLaunch", () => { + const agentConfigs = resolveAgentConfigs({}); + + test("returns null when there are no sources", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase(), + attachments: undefined, + agentConfigs, + }); + expect(build).toBeNull(); + }); + + test("returns null when there are no enabled agents", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "hi" }), + attachments: undefined, + agentConfigs: [], + }); + expect(build).toBeNull(); + }); + + test("prompt-only → terminal launch via default agent (claude)", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "refactor the auth middleware" }), + attachments: undefined, + agentConfigs, + }); + expect(build?.kind).toBe("terminal"); + if (build?.kind !== "terminal") throw new Error("wrong kind"); + expect(build.launch.name).toBe("Claude"); + expect(build.launch.command).toContain("claude"); + expect(build.launch.command).toContain("refactor the auth middleware"); + expect(build.launch.attachmentNames).toEqual([]); + expect(build.attachmentsToWrite).toEqual([]); + }); + + test("linked internal task renders into the command", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ + prompt: "do it", + linkedIssues: [ + { + source: "internal", + taskId: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth", + }, + ], + }), + attachments: undefined, + agentConfigs, + }); + if (build?.kind !== "terminal") throw new Error("wrong kind"); + expect(build.launch.command).toContain("Refactor auth"); + }); + + test("attachments produce disk-ready bytes + matching names", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "fix" }), + attachments: [ + { + data: "data:text/plain;base64,AQID", // [1,2,3] + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + agentConfigs, + }); + if (build?.kind !== "terminal") throw new Error("wrong kind"); + expect(build.attachmentsToWrite).toHaveLength(1); + expect(build.attachmentsToWrite[0]?.filename).toBe("logs.txt"); + expect(Array.from(build.attachmentsToWrite[0]?.data ?? [])).toEqual([ + 1, 2, 3, + ]); + expect(build.launch.attachmentNames).toEqual(["logs.txt"]); + }); + + test("chat agent → chat launch with initialPrompt + files", async () => { + const chatOnlyConfigs = agentConfigs.map((c) => + c.id === "superset-chat" + ? { ...c, enabled: true } + : { ...c, enabled: false }, + ); + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "help me refactor" }), + attachments: [ + { + data: "data:text/plain;base64,AQID", + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + agentConfigs: chatOnlyConfigs, + }); + expect(build?.kind).toBe("chat"); + if (build?.kind !== "chat") throw new Error("wrong kind"); + expect(build.launch.initialPrompt).toContain("help me refactor"); + expect(build.launch.initialFiles).toHaveLength(1); + expect(build.launch.initialFiles?.[0]?.data).toBe( + "data:text/plain;base64,AQID", + ); + expect(build.launch.initialFiles?.[0]?.filename).toBe("logs.txt"); + }); + + test("disabled agent → null", async () => { + const disabled = agentConfigs.map((c) => ({ ...c, enabled: false })); + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "hi" }), + attachments: undefined, + agentConfigs: disabled, + }); + expect(build).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts new file mode 100644 index 00000000000..141b6d4f241 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -0,0 +1,511 @@ +import { isTerminalAgentDefinition } from "@superset/shared/agent-catalog"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import type { + PendingChatLaunch, + PendingTerminalLaunch, + PendingWorkspaceRow, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { buildLaunchSpec } from "shared/context/buildLaunchSpec"; +import { buildLaunchContext } from "shared/context/composer"; +import { defaultContributorRegistry } from "shared/context/contributors"; +import type { + AgentLaunchSpec, + AttachmentFile, + ContentPart, + LaunchSource, + ResolveCtx, +} from "shared/context/types"; +import { + buildPromptCommandFromAgentConfig, + getCommandFromAgentConfig, + getFallbackAgentId, + indexResolvedAgentConfigs, + type ResolvedAgentConfig, +} from "shared/utils/agent-settings"; + +export interface LoadedAttachment { + data: string; // base64 data URL + mediaType: string; + filename: string; +} + +export interface BuildForkAgentLaunchInputs { + pending: Pick< + PendingWorkspaceRow, + "projectId" | "prompt" | "linkedIssues" | "linkedPR" + >; + attachments: LoadedAttachment[] | undefined; + agentConfigs: ResolvedAgentConfig[]; + /** + * Host-service client for fetching issue/PR bodies. When provided, + * the resolvers call `getGitHubIssueContent` / `getGitHubPullRequestContent` + * for full bodies. When null, falls back to title-only from the pending row. + */ + hostServiceClient?: { + workspaceCreation: { + getGitHubIssueContent: { + query: (input: { projectId: string; issueNumber: number }) => Promise<{ + number: number; + title: string; + body: string; + url: string; + state: string; + author: string | null; + }>; + }; + getGitHubPullRequestContent: { + query: (input: { projectId: string; prNumber: number }) => Promise<{ + number: number; + title: string; + body: string; + url: string; + state: string; + branch: string; + baseBranch: string; + author: string | null; + }>; + }; + }; + }; +} + +/** + * The pending page writes one of these to the pending row after + * host-service.create resolves; the V2 workspace page consumes it on + * mount. See apps/desktop/docs/V2_LAUNCH_CONTEXT.md. + */ +export type PendingLaunchBuild = + | { + kind: "terminal"; + launch: PendingTerminalLaunch; + /** + * Binary payloads to write to `<worktree>/.superset/attachments/` + * via workspaceTrpc.filesystem before setting `row.terminalLaunch`. + * Already named with collision-safe filenames matching + * `launch.attachmentNames` and any inline refs in `launch.command`. + */ + attachmentsToWrite: Array<{ + filename: string; + mediaType: string; + data: Uint8Array; + }>; + } + | { kind: "chat"; launch: PendingChatLaunch }; + +/** + * Builds a PendingLaunchBuild record describing how the V2 workspace + * page should dispatch the agent once it mounts. The pending page owns + * applying this to the pending row (and writing terminal attachments + * to disk). Returns null for no-op launches (e.g. no sources, no agent + * enabled). + * + * When `hostServiceClient` is passed in, issues and PRs get full bodies + * fetched via host-service. Internal tasks get descriptions fetched via + * the cloud API (apiTrpcClient.task.byId). Either fetch failing + * degrades to title-only from the pending row — non-fatal. + */ +export async function buildForkAgentLaunch( + inputs: BuildForkAgentLaunchInputs, +): Promise<PendingLaunchBuild | null> { + const agentId = getFallbackAgentId(inputs.agentConfigs); + if (!agentId) return null; + + const agentConfig = indexResolvedAgentConfigs(inputs.agentConfigs).get( + agentId, + ); + if (!agentConfig || !agentConfig.enabled) return null; + + const sources = buildLaunchSourcesFromPending( + inputs.pending, + inputs.attachments, + ); + if (sources.length === 0) return null; + + const ctx = await buildLaunchContext( + { + projectId: inputs.pending.projectId, + sources, + agent: { id: agentId }, + }, + { + contributors: defaultContributorRegistry, + resolveCtx: buildResolveCtxFromPending( + inputs.pending, + inputs.hostServiceClient, + ), + }, + ); + const spec = buildLaunchSpec(ctx, agentConfig); + if (!spec) return null; + + if (isTerminalAgentDefinition(agentConfig)) { + return buildTerminalLaunch(spec, agentConfig); + } + return buildChatLaunch(spec, agentConfig); +} + +// --------------------------------------------------------------------------- +// Terminal launch assembly +// --------------------------------------------------------------------------- + +function buildTerminalLaunch( + spec: AgentLaunchSpec, + agentConfig: Extract<ResolvedAgentConfig, { kind: "terminal" }>, +): PendingLaunchBuild | null { + const { attachmentsToWrite, inlineByIndex } = assignFilenamesAndCollect( + spec.user, + spec.attachments, + ); + const promptText = flattenUserContentForTerminal(spec.user, inlineByIndex); + + const command = promptText.trim() + ? buildPromptCommandFromAgentConfig({ + prompt: promptText, + randomId: crypto.randomUUID(), + config: agentConfig, + }) + : getCommandFromAgentConfig(agentConfig); + if (!command) return null; + + return { + kind: "terminal", + launch: { + command, + name: agentConfig.label, + attachmentNames: attachmentsToWrite.map((a) => a.filename), + }, + attachmentsToWrite, + }; +} + +function flattenUserContentForTerminal( + user: ContentPart[], + inlineByIndex: Map<number, string>, +): string { + const out: string[] = []; + user.forEach((part, index) => { + if (part.type === "text") { + out.push(part.text); + return; + } + const filename = inlineByIndex.get(index); + if (!filename) return; + out.push(`![${filename}](.superset/attachments/${filename})`); + }); + return out.join("").trim(); +} + +// --------------------------------------------------------------------------- +// Chat launch assembly +// --------------------------------------------------------------------------- + +function buildChatLaunch( + spec: AgentLaunchSpec, + agentConfig: Extract<ResolvedAgentConfig, { kind: "chat" }>, +): PendingLaunchBuild { + const initialPrompt = extractTextParts(spec.user).join("\n\n").trim(); + const binaries = [ + ...spec.user.filter((p) => p.type !== "text"), + ...spec.attachments.filter((p) => p.type !== "text"), + ]; + const initialFiles = binaries.length + ? binaries.map((part) => ({ + data: toBase64DataUrl(part), + mediaType: part.mediaType, + filename: part.type === "file" ? part.filename : undefined, + })) + : undefined; + + return { + kind: "chat", + launch: { + initialPrompt: initialPrompt || undefined, + initialFiles, + model: agentConfig.model, + taskSlug: spec.taskSlug, + }, + }; +} + +function extractTextParts(parts: ContentPart[]): string[] { + return parts + .filter( + (p): p is Extract<ContentPart, { type: "text" }> => p.type === "text", + ) + .map((p) => p.text); +} + +function toBase64DataUrl(part: Exclude<ContentPart, { type: "text" }>): string { + return `data:${part.mediaType};base64,${bytesToBase64(part.data)}`; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i] ?? 0); + } + return btoa(binary); +} + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +// --------------------------------------------------------------------------- +// Shared: collect binary parts into disk-ready attachments with stable names +// --------------------------------------------------------------------------- + +function assignFilenamesAndCollect( + user: ContentPart[], + attachments: ContentPart[], +): { + attachmentsToWrite: Array<{ + filename: string; + mediaType: string; + data: Uint8Array; + }>; + inlineByIndex: Map<number, string>; +} { + const used = new Set<string>(); + const out: Array<{ filename: string; mediaType: string; data: Uint8Array }> = + []; + const inlineByIndex = new Map<number, string>(); + + user.forEach((part, index) => { + if (part.type === "text") return; + const filename = nextUniqueName(part, used, out.length); + inlineByIndex.set(index, filename); + out.push({ filename, mediaType: part.mediaType, data: part.data }); + }); + + for (const part of attachments) { + if (part.type === "text") continue; + const filename = nextUniqueName(part, used, out.length); + out.push({ filename, mediaType: part.mediaType, data: part.data }); + } + + return { attachmentsToWrite: out, inlineByIndex }; +} + +function nextUniqueName( + part: Exclude<ContentPart, { type: "text" }>, + used: Set<string>, + fallbackIndex: number, +): string { + const raw = part.type === "file" ? part.filename : undefined; + const sanitized = raw ? sanitizeFilename(raw) : ""; + let name = sanitized; + if (!name) { + let counter = fallbackIndex + 1; + do { + name = `attachment_${counter}`; + counter++; + } while (used.has(name)); + } else if (used.has(name)) { + const segs = name.split("."); + const ext = segs.length > 1 ? segs.pop() : undefined; + const base = segs.join("."); + let counter = 1; + let candidate: string; + do { + candidate = ext ? `${base}_${counter}.${ext}` : `${name}_${counter}`; + counter++; + } while (used.has(candidate)); + name = candidate; + } + used.add(name); + return name; +} + +function sanitizeFilename(filename: string): string { + const cleaned = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + return cleaned.trim() ? cleaned : ""; +} + +// --------------------------------------------------------------------------- +// Source + ResolveCtx (unchanged from prior implementation) +// --------------------------------------------------------------------------- + +export function buildLaunchSourcesFromPending( + pending: BuildForkAgentLaunchInputs["pending"], + attachments: LoadedAttachment[] | undefined, +): LaunchSource[] { + const sources: LaunchSource[] = []; + + const prompt = pending.prompt?.trim(); + if (prompt) { + sources.push({ + kind: "user-prompt", + content: [{ type: "text", text: prompt }], + }); + } + + for (const issue of pending.linkedIssues) { + if (issue.source === "internal" && issue.taskId) { + sources.push({ kind: "internal-task", id: issue.taskId }); + } else if (issue.source === "github" && issue.url) { + sources.push({ kind: "github-issue", url: issue.url }); + } + } + + if (pending.linkedPR?.url) { + sources.push({ kind: "github-pr", url: pending.linkedPR.url }); + } + + for (const attachment of attachments ?? []) { + sources.push({ + kind: "attachment", + file: dataUrlAttachmentToBytes(attachment), + }); + } + + return sources; +} + +function dataUrlAttachmentToBytes(loaded: LoadedAttachment): AttachmentFile { + const match = loaded.data.match(/^data:[^;]+;base64,(.+)$/); + const base64 = match?.[1] ?? ""; + return { + data: base64ToBytes(base64), + mediaType: loaded.mediaType, + filename: loaded.filename, + }; +} + +function slugifyTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} + +function buildResolveCtxFromPending( + pending: BuildForkAgentLaunchInputs["pending"], + client?: BuildForkAgentLaunchInputs["hostServiceClient"], +): ResolveCtx { + return { + projectId: pending.projectId, + signal: new AbortController().signal, + + fetchIssue: async (url) => { + const match = pending.linkedIssues.find( + (i) => i.source === "github" && i.url === url, + ); + if (!match) { + throw Object.assign(new Error(`Issue not found: ${url}`), { + status: 404, + }); + } + + // Try host-service for full body; fall back to pending-row metadata. + if (client && match.number) { + try { + const data = + await client.workspaceCreation.getGitHubIssueContent.query({ + projectId: pending.projectId, + issueNumber: match.number, + }); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + slug: match.slug || slugifyTitle(data.title), + }; + } catch (err) { + console.warn( + `[v2-launch] getGitHubIssueContent failed for #${match.number}, using title-only`, + err, + ); + } + } + + return { + number: match.number ?? 0, + url: match.url ?? url, + title: match.title, + body: "", + slug: match.slug, + }; + }, + + fetchPullRequest: async (url) => { + if (!pending.linkedPR || pending.linkedPR.url !== url) { + throw Object.assign(new Error(`PR not found: ${url}`), { + status: 404, + }); + } + + // Try host-service for full body + branch; fall back to pending-row. + if (client) { + try { + const data = + await client.workspaceCreation.getGitHubPullRequestContent.query({ + projectId: pending.projectId, + prNumber: pending.linkedPR.prNumber, + }); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + branch: data.branch, + }; + } catch (err) { + console.warn( + `[v2-launch] getGitHubPullRequestContent failed for #${pending.linkedPR.prNumber}, using title-only`, + err, + ); + } + } + + return { + number: pending.linkedPR.prNumber, + url: pending.linkedPR.url, + title: pending.linkedPR.title, + body: "", + branch: "", + }; + }, + + fetchInternalTask: async (id) => { + const match = pending.linkedIssues.find( + (i) => i.source === "internal" && i.taskId === id, + ); + if (!match) { + throw Object.assign(new Error(`Task not found: ${id}`), { + status: 404, + }); + } + + // Fetch full task from Superset cloud API (same source as task view). + try { + const task = await apiTrpcClient.task.byId.query(id); + if (task) { + return { + id: task.id, + slug: match.slug || slugifyTitle(task.title), + title: task.title, + description: task.description ?? null, + }; + } + } catch (err) { + console.warn( + `[v2-launch] task.byId failed for ${id}, using title-only`, + err, + ); + } + + return { + id, + slug: match.slug, + title: match.title, + description: null, + }; + }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts index 36d585f5d7b..fd2dd0a6bf6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts @@ -30,6 +30,8 @@ function makePending( linkedPR: null, attachmentCount: 0, runSetupScript: true, + terminalLaunch: null, + chatLaunch: null, ...overrides, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts new file mode 100644 index 00000000000..1b4d18999bf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -0,0 +1,211 @@ +import { toast } from "@superset/ui/sonner"; +import { env } from "renderer/env.renderer"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import type { + PendingChatLaunch, + PendingTerminalLaunch, + PendingWorkspaceRow, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; +import { + buildForkAgentLaunch, + type LoadedAttachment, +} from "./buildForkAgentLaunch"; + +export interface DispatchForkLaunchInputs { + workspaceId: string; + pending: Pick< + PendingWorkspaceRow, + "projectId" | "prompt" | "linkedIssues" | "linkedPR" | "hostTarget" + >; + loadedAttachments: LoadedAttachment[] | undefined; + agentConfigs: ResolvedAgentConfig[]; + activeHostUrl: string | null; + onApplyToRow: (patch: { + terminalLaunch?: PendingTerminalLaunch | null; + chatLaunch?: PendingChatLaunch | null; + }) => void; +} + +/** + * After host-service.create resolves, run the composer pipeline and + * stash the launch intent on the pending row. The V2 workspace page's + * useConsumePendingLaunch mount effect picks it up. + * + * For terminal launches we also write attachment bytes to + * `<worktree>/.superset/attachments/` now — the worktree exists and + * workspaceTrpc.filesystem is available. Chat launches carry their + * binaries as base64 data URLs inline (existing ChatLaunchConfig shape). + */ +export async function dispatchForkLaunch({ + workspaceId, + pending, + loadedAttachments, + agentConfigs, + activeHostUrl, + onApplyToRow, +}: DispatchForkLaunchInputs): Promise<void> { + console.log("[v2-launch] dispatchForkLaunch: start", { + workspaceId, + projectId: pending.projectId, + attachmentCount: loadedAttachments?.length ?? 0, + agentConfigCount: agentConfigs.length, + }); + + const hostUrl = resolveHostUrl(pending.hostTarget, activeHostUrl); + const hostClient = hostUrl ? getHostServiceClientByUrl(hostUrl) : undefined; + + let build: Awaited<ReturnType<typeof buildForkAgentLaunch>>; + try { + build = await buildForkAgentLaunch({ + pending, + attachments: loadedAttachments, + agentConfigs, + hostServiceClient: hostClient, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[v2-launch] buildForkAgentLaunch failed:", err); + toast.error("Couldn't prepare agent launch", { description: msg }); + return; + } + + console.log("[v2-launch] dispatchForkLaunch: built", { + kind: build?.kind ?? null, + terminalCommand: + build?.kind === "terminal" + ? build.launch.command.slice(0, 120) + : undefined, + chatPrompt: + build?.kind === "chat" + ? build.launch.initialPrompt?.slice(0, 120) + : undefined, + attachmentsToWrite: + build?.kind === "terminal" ? build.attachmentsToWrite.length : 0, + }); + + if (!build) { + console.warn( + "[v2-launch] dispatchForkLaunch: buildForkAgentLaunch returned null — no launch", + ); + // Only warn if the user gave input worth launching on (prompt text, + // linked context, or attachments). An empty workspace-create with no + // agent enabled is a valid case and shouldn't surface a toast. + const userGaveInput = + (pending.prompt?.trim().length ?? 0) > 0 || + pending.linkedIssues.length > 0 || + !!pending.linkedPR || + (loadedAttachments?.length ?? 0) > 0; + if (userGaveInput) { + toast.warning("Workspace created but no agent launched", { + description: + "Enable an agent in Settings → Agents to auto-launch on new workspaces.", + }); + } + return; + } + + if (build.kind === "chat") { + onApplyToRow({ chatLaunch: build.launch }); + console.log("[v2-launch] dispatchForkLaunch: chatLaunch applied to row"); + return; + } + + if (!hostUrl) { + console.warn("[v2-launch] host-service URL not resolved; skip launch"); + toast.error("Couldn't reach host service", { + description: "Agent didn't launch. Check your host connection.", + }); + return; + } + + try { + if (build.attachmentsToWrite.length > 0) { + await writeAttachmentsToWorktree({ + hostUrl, + workspaceId, + attachments: build.attachmentsToWrite, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[v2-launch] failed to write attachments:", err); + toast.warning("Attachments didn't save to the workspace", { + description: `Agent will launch without files. ${msg}`, + }); + // keep going — terminal launch still useful even without files + } + + onApplyToRow({ terminalLaunch: build.launch }); + console.log("[v2-launch] dispatchForkLaunch: terminalLaunch applied to row", { + workspaceId, + }); +} + +function resolveHostUrl( + hostTarget: PendingWorkspaceRow["hostTarget"], + activeHostUrl: string | null, +): string | null { + if (hostTarget.kind === "local") return activeHostUrl; + return `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; +} + +async function writeAttachmentsToWorktree({ + hostUrl, + workspaceId, + attachments, +}: { + hostUrl: string; + workspaceId: string; + attachments: Array<{ + filename: string; + mediaType: string; + data: Uint8Array; + }>; +}): Promise<void> { + const client = getHostServiceClientByUrl(hostUrl); + const workspace = await client.workspace.get.query({ id: workspaceId }); + const worktreePath: string | undefined = ( + workspace as { worktreePath?: string } + ).worktreePath; + if (!worktreePath) { + console.warn( + "[v2-launch] workspace has no worktreePath; skipping attachments", + ); + throw new Error("Workspace has no worktreePath"); + } + + const dir = joinPath(worktreePath, ".superset/attachments"); + try { + await client.filesystem.createDirectory.mutate({ + workspaceId, + absolutePath: dir, + }); + } catch { + // directory may already exist; writeFile will fail loudly if it doesn't + } + + for (const attachment of attachments) { + await client.filesystem.writeFile.mutate({ + workspaceId, + absolutePath: joinPath(dir, attachment.filename), + content: { + kind: "base64", + data: bytesToBase64(attachment.data), + }, + }); + } +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i] ?? 0); + } + return btoa(binary); +} + +function joinPath(a: string, b: string): string { + if (a.endsWith("/")) return `${a}${b}`; + return `${a}/${b}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx index 97879eaf353..660fe0428fe 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx @@ -1,3 +1,4 @@ +import { toast } from "@superset/ui/sonner"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; @@ -6,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { GoGitBranch } from "react-icons/go"; import { HiCheck, HiExclamationTriangle } from "react-icons/hi2"; import { env } from "renderer/env.renderer"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { @@ -25,6 +27,7 @@ import { buildForkPayload, } from "./buildIntentPayload"; import { buildSetupPaneLayout } from "./buildSetupPaneLayout"; +import { dispatchForkLaunch } from "./dispatchForkLaunch"; /** * Pending workspace progress page. @@ -51,6 +54,8 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { const createWorkspace = useCreateDashboardWorkspace(); const checkoutWorkspace = useCheckoutDashboardWorkspace(); const adoptWorktree = useAdoptWorktree(); + const trpcUtils = electronTrpc.useUtils(); + const { activeHostUrl } = useLocalHostService(); return useCallback(async () => { if (!pending) return; @@ -66,21 +71,25 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { terminals?: Array<{ id: string; role: string; label: string }>; warnings?: string[]; }; + let loadedAttachments: + | Array<{ data: string; mediaType: string; filename: string }> + | undefined; switch (pending.intent) { case "fork": { - let attachments: - | Array<{ data: string; mediaType: string; filename: string }> - | undefined; if (pending.attachmentCount > 0) { try { - attachments = await loadAttachments(pendingId); - } catch { - // proceed without + loadedAttachments = await loadAttachments(pendingId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[v2-launch] loadAttachments failed:", err); + toast.warning("Couldn't load saved attachments", { + description: `Workspace will be created without files. ${msg}`, + }); } } result = await createWorkspace( - buildForkPayload(pendingId, pending, attachments), + buildForkPayload(pendingId, pending, loadedAttachments), ); break; } @@ -96,6 +105,36 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { } } + // V2 dispatch: after host-service.create resolves, build the launch + // plan and stash it on the pending row. The V2 workspace page's + // useConsumePendingLaunch mount-effect picks it up and opens the + // pane. See apps/desktop/docs/V2_LAUNCH_CONTEXT.md. + // + // Fetch agent configs imperatively here rather than reading from + // a useQuery hook — a not-yet-resolved query would silently skip + // the dispatch, permanently losing the launch for a successful + // workspace create. + if (pending.intent === "fork" && result.workspace?.id) { + const agentConfigs = await trpcUtils.settings.getAgentPresets.fetch(); + await dispatchForkLaunch({ + workspaceId: result.workspace.id, + pending, + loadedAttachments, + agentConfigs, + activeHostUrl, + onApplyToRow: (patch) => { + collections.pendingWorkspaces.update(pendingId, (draft) => { + if (patch.terminalLaunch !== undefined) { + draft.terminalLaunch = patch.terminalLaunch; + } + if (patch.chatLaunch !== undefined) { + draft.chatLaunch = patch.chatLaunch; + } + }); + }, + }); + } + collections.pendingWorkspaces.update(pendingId, (draft) => { draft.status = "succeeded"; draft.workspaceId = result.workspace?.id ?? null; @@ -117,6 +156,8 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { adoptWorktree, pending, pendingId, + trpcUtils, + activeHostUrl, ]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts new file mode 100644 index 00000000000..f12ad26d382 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts @@ -0,0 +1 @@ +export { useConsumePendingLaunch } from "./useConsumePendingLaunch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts new file mode 100644 index 00000000000..4cefc4dcafa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts @@ -0,0 +1,216 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useEffect, useRef } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { StoreApi } from "zustand/vanilla"; +import type { + ChatPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +interface UseConsumePendingLaunchArgs { + workspaceId: string; + store: StoreApi<WorkspaceStore<PaneViewerData>>; +} + +/** + * Consumes a pending row's `terminalLaunch` / `chatLaunch` stashed by + * the pending page after host-service.create resolved. Opens the + * corresponding pane in the V2 `@superset/panes` store, clears the + * field so subsequent mounts don't re-dispatch. + * + * Pattern mirrors useV2PresetExecution: live-query a record, open a + * pane with the store, call workspaceTrpc for any PTY side effects. + * See apps/desktop/docs/V2_LAUNCH_CONTEXT.md "Dispatch architecture". + */ +export function useConsumePendingLaunch({ + workspaceId, + store, +}: UseConsumePendingLaunchArgs): void { + const collections = useCollections(); + const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); + const ensureSessionRef = useRef(ensureSession); + ensureSessionRef.current = ensureSession; + const consumedRef = useRef<Set<string>>(new Set()); + + const { data: matches } = useLiveQuery( + (q) => + q + .from({ pw: collections.pendingWorkspaces }) + .where(({ pw }) => eq(pw.workspaceId, workspaceId)) + .select(({ pw }) => ({ ...pw })), + [collections, workspaceId], + ); + + const pending = matches?.[0] ?? null; + + const updateRow = useCallback( + (patch: Partial<PendingWorkspaceRow>) => { + if (!pending) return; + collections.pendingWorkspaces.update(pending.id, (draft) => { + Object.assign(draft, patch); + }); + }, + [collections, pending], + ); + + useEffect(() => { + if (!pending) { + console.log("[v2-launch] useConsumePendingLaunch: no pending row yet", { + workspaceId, + }); + return; + } + + const terminalKey = pending.terminalLaunch + ? `${pending.id}:terminal` + : null; + const chatKey = pending.chatLaunch ? `${pending.id}:chat` : null; + + console.log("[v2-launch] useConsumePendingLaunch: tick", { + workspaceId, + pendingId: pending.id, + status: pending.status, + hasTerminalLaunch: !!pending.terminalLaunch, + hasChatLaunch: !!pending.chatLaunch, + terminalConsumed: terminalKey + ? consumedRef.current.has(terminalKey) + : null, + chatConsumed: chatKey ? consumedRef.current.has(chatKey) : null, + }); + + if (terminalKey && !consumedRef.current.has(terminalKey)) { + consumedRef.current.add(terminalKey); + console.log("[v2-launch] useConsumePendingLaunch: consuming terminal", { + command: pending.terminalLaunch?.command.slice(0, 120), + }); + void consumeTerminalLaunch({ + pending, + store, + ensureSession: ensureSessionRef.current.mutateAsync, + clear: () => updateRow({ terminalLaunch: null }), + }); + } + + if (chatKey && !consumedRef.current.has(chatKey)) { + consumedRef.current.add(chatKey); + console.log("[v2-launch] useConsumePendingLaunch: consuming chat"); + consumeChatLaunch({ + pending, + store, + clear: () => updateRow({ chatLaunch: null }), + }); + } + }, [pending, store, updateRow, workspaceId]); +} + +async function consumeTerminalLaunch({ + pending, + store, + ensureSession, + clear, +}: { + pending: PendingWorkspaceRow; + store: StoreApi<WorkspaceStore<PaneViewerData>>; + ensureSession: (input: { + terminalId: string; + workspaceId: string; + initialCommand?: string; + }) => Promise<unknown>; + clear: () => void; +}): Promise<void> { + const launch = pending.terminalLaunch; + if (!launch || !pending.workspaceId) { + console.warn("[v2-launch] consumeTerminalLaunch: bailing", { + hasLaunch: !!launch, + hasWorkspaceId: !!pending.workspaceId, + }); + // Defensive — shouldn't happen if the caller checked terminalLaunch + // already. Worth a toast so we see it in practice. + toast.error("Couldn't open agent pane", { + description: + "Missing launch data — please retry from the workspace menu.", + }); + return; + } + + const terminalId = crypto.randomUUID(); + console.log("[v2-launch] consumeTerminalLaunch: ensureSession", { + terminalId, + workspaceId: pending.workspaceId, + commandPreview: launch.command.slice(0, 120), + }); + + try { + await ensureSession({ + terminalId, + workspaceId: pending.workspaceId, + initialCommand: launch.command, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn( + "[v2-launch] consumeTerminalLaunch: ensureSession failed:", + err, + ); + toast.error("Couldn't start agent terminal", { description: msg }); + return; + } + + const data: TerminalPaneData = { terminalId }; + console.log("[v2-launch] consumeTerminalLaunch: addTab", { terminalId }); + store.getState().addTab({ + panes: [ + { + kind: "terminal", + titleOverride: launch.name, + data: data as PaneViewerData, + }, + ], + }); + clear(); + console.log("[v2-launch] consumeTerminalLaunch: done + cleared"); +} + +function consumeChatLaunch({ + pending, + store, + clear, +}: { + pending: PendingWorkspaceRow; + store: StoreApi<WorkspaceStore<PaneViewerData>>; + clear: () => void; +}): void { + const launch = pending.chatLaunch; + if (!launch) return; + + const data: ChatPaneData = { + sessionId: null, + launchConfig: { + initialPrompt: launch.initialPrompt, + initialFiles: launch.initialFiles, + model: launch.model, + taskSlug: launch.taskSlug, + }, + }; + + console.log("[v2-launch] consumeChatLaunch: addTab", { + hasPrompt: !!launch.initialPrompt, + fileCount: launch.initialFiles?.length ?? 0, + }); + store.getState().addTab({ + panes: [ + { + kind: "chat", + data: data as PaneViewerData, + }, + ], + }); + clear(); + console.log("[v2-launch] consumeChatLaunch: done + cleared"); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/ChatPane.tsx index 4c835ad4a32..b1978b922d5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/ChatPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/ChatPane.tsx @@ -1,3 +1,4 @@ +import type { ChatLaunchConfig } from "shared/tabs-types"; import { SessionSelector } from "./components/SessionSelector"; import { ChatPaneInterface as WorkspaceChatInterface } from "./components/WorkspaceChatInterface"; import { useWorkspaceChatController } from "./hooks/useWorkspaceChatController"; @@ -6,10 +7,14 @@ export function ChatPane({ onSessionIdChange, sessionId, workspaceId, + initialLaunchConfig, + onConsumeLaunchConfig, }: { onSessionIdChange: (sessionId: string | null) => void; sessionId: string | null; workspaceId: string; + initialLaunchConfig?: ChatLaunchConfig | null; + onConsumeLaunchConfig?: () => void; }) { const { organizationId, @@ -41,7 +46,8 @@ export function ChatPane({ <div className="min-h-0 flex-1"> <WorkspaceChatInterface getOrCreateSession={getOrCreateSession} - initialLaunchConfig={null} + initialLaunchConfig={initialLaunchConfig ?? null} + onConsumeLaunchConfig={onConsumeLaunchConfig} isFocused onResetSession={handleNewChat} sessionId={sessionId} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx index 5d84194d9dc..ef8d651a249 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx @@ -215,6 +215,7 @@ function getLaunchConfigKey( export function ChatPaneInterface({ sessionId, initialLaunchConfig, + onConsumeLaunchConfig, workspaceId, organizationId, cwd, @@ -764,6 +765,7 @@ export function ChatPaneInterface({ consumedLaunchConfigRef.current = launchConfigKey; delete autoLaunchAttemptsRef.current[launchConfigKey]; delete autoLaunchSessionLockRef.current[launchConfigKey]; + onConsumeLaunchConfig?.(); captureChatEvent("chat_message_sent", { session_id: targetSessionId, @@ -814,6 +816,7 @@ export function ChatPaneInterface({ setRuntimeErrorMessage, onUserMessageSubmitted, thinkingLevel, + onConsumeLaunchConfig, ]); const handleStop = useCallback( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/types.ts index a0d6558d8f5..37b3be8a2f1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/types.ts @@ -12,6 +12,12 @@ export interface ChatRawSnapshot { export interface ChatPaneInterfaceProps { sessionId: string | null; initialLaunchConfig: ChatLaunchConfig | null; + /** + * Called after the ChatPaneInterface successfully auto-submits the + * initial launch config so the owning pane can clear its persisted + * launchConfig and not re-trigger on re-render. + */ + onConsumeLaunchConfig?: () => void; workspaceId: string; organizationId: string | null; cwd: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index aa85c5c283f..d889057ad2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -293,10 +293,18 @@ export function usePaneRegistry( onSessionIdChange={(sessionId) => ctx.actions.updateData({ sessionId, + launchConfig: data.launchConfig ?? null, } as PaneViewerData) } sessionId={data.sessionId} workspaceId={workspaceId} + initialLaunchConfig={data.launchConfig ?? null} + onConsumeLaunchConfig={() => + ctx.actions.updateData({ + sessionId: data.sessionId, + launchConfig: null, + } as PaneViewerData) + } /> ); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index c206d168f64..a080470d635 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -41,6 +41,7 @@ import { AddTabMenu } from "./components/AddTabMenu"; import { V2PresetsBar } from "./components/V2PresetsBar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; +import { useConsumePendingLaunch } from "./hooks/useConsumePendingLaunch"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; @@ -148,6 +149,7 @@ function WorkspaceContent({ workspaceId, projectId, }); + useConsumePendingLaunch({ workspaceId, store }); const paneRegistry = usePaneRegistry(workspaceId); const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); const rightSidebarOpenViewWidth = useRightSidebarOpenViewWidth(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index d16e1b4c5d3..da939a78a3c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -12,6 +12,21 @@ export interface TerminalPaneData { export interface ChatPaneData { sessionId: string | null; + /** + * Transient initial launch config for a freshly-opened chat pane. + * Cleared by the chat pane on first consume. Set by the V2 workspace + * page's useConsumePendingLaunch when a pending chat launch exists. + */ + launchConfig?: { + initialPrompt?: string; + initialFiles?: Array<{ + data: string; + mediaType: string; + filename?: string; + }>; + model?: string; + taskSlug?: string; + } | null; } export interface BrowserPaneData { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx index c7ffee1bf1c..cbaa45df94c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -11,6 +11,7 @@ import { import { Input } from "@superset/ui/input"; import { isEnterSubmit } from "@superset/ui/lib/keyboard"; import { cn } from "@superset/ui/utils"; +import type { FileUIPart } from "ai"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowUpIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -129,15 +130,32 @@ export function PromptGroup({ // ── Submit (fork) ──────────────────────────────────────────────── const handleCreate = useSubmitWorkspace(projectId); - const handlePromptSubmit = useCallback(() => { - void handleCreate(); - }, [handleCreate]); + const handlePromptSubmit = useCallback( + (message: { text?: string; files?: FileUIPart[] }) => { + // Library converts blob: → data: URLs before calling us; pass them + // through. We intentionally do not read attachments from the + // provider here — the library clears + revokes before onSubmit, so + // the provider's state is stale by this point. + const files = (message.files ?? []) + .filter((f) => typeof f.url === "string" && f.url.length > 0) + .map((f) => ({ + url: f.url, + mediaType: f.mediaType, + filename: f.filename, + })); + void handleCreate(files); + }, + [handleCreate], + ); useEffect(() => { if (!isNewWorkspaceModalOpen) return; const handler = (e: KeyboardEvent) => { if (!isEnterSubmit(e, { requireMod: true })) return; e.preventDefault(); + // Keyboard fallback: submit without attachments. Inside the + // modal's form focus, PromptInput's own Enter handler fires + // instead and routes through handlePromptSubmit with files. void handleCreate(); }; window.addEventListener("keydown", handler); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts index 9d24b03b746..1fd123714d3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts @@ -1,4 +1,3 @@ -import { useProviderAttachments } from "@superset/ui/ai-elements/prompt-input"; import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; @@ -7,72 +6,74 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect import { useDashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; import { resolveNames } from "./resolveNames"; +export interface SubmitAttachment { + url: string; // data: URL already (library converts blob→data before onSubmit) + mediaType: string; + filename?: string; +} + /** * Returns a callback that submits a fork (new branch from base): * resolve names → store attachments → insert pending row → close modal → * navigate to pending page. The page owns the host-service mutation — * see V2_WORKSPACE_CREATION.md §3. + * + * Files come via the PromptInput's `onSubmit({ text, files })` payload + * (already converted from blob: → data: by the library before it calls + * us). We do not read from `useProviderAttachments().takeFiles()` here: + * the library clears provider state + revokes blob URLs *before* + * invoking onSubmit, so the ref is stale by the time we'd see it. */ export function useSubmitWorkspace(projectId: string | null) { const navigate = useNavigate(); const { closeAndResetDraft, draft } = useDashboardNewWorkspaceDraft(); - const attachments = useProviderAttachments(); const collections = useCollections(); - return useCallback(async () => { - if (!projectId) { - toast.error("Select a project first"); - return; - } + return useCallback( + async (files: SubmitAttachment[] = []) => { + if (!projectId) { + toast.error("Select a project first"); + return; + } - const { branchName, workspaceName } = resolveNames(draft); + const { branchName, workspaceName } = resolveNames(draft); + const pendingId = crypto.randomUUID(); - const pendingId = crypto.randomUUID(); - const detachedFiles = attachments.takeFiles(); - if (detachedFiles.length > 0) { - try { - await storeAttachments(pendingId, detachedFiles); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to store attachments", - ); - return; - } finally { - for (const file of detachedFiles) { - if (file.url?.startsWith("blob:")) URL.revokeObjectURL(file.url); + if (files.length > 0) { + try { + await storeAttachments(pendingId, files); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to store attachments", + ); + return; } } - } - collections.pendingWorkspaces.insert({ - id: pendingId, - projectId, - intent: "fork", - name: workspaceName, - branchName, - prompt: draft.prompt, - baseBranch: draft.baseBranch ?? null, - baseBranchSource: draft.baseBranchSource ?? null, - runSetupScript: draft.runSetupScript, - linkedIssues: draft.linkedIssues, - linkedPR: draft.linkedPR, - hostTarget: draft.hostTarget, - attachmentCount: detachedFiles.length, - status: "creating", - error: null, - workspaceId: null, - warnings: [], - createdAt: new Date(), - }); + collections.pendingWorkspaces.insert({ + id: pendingId, + projectId, + intent: "fork", + name: workspaceName, + branchName, + prompt: draft.prompt, + baseBranch: draft.baseBranch ?? null, + baseBranchSource: draft.baseBranchSource ?? null, + runSetupScript: draft.runSetupScript, + linkedIssues: draft.linkedIssues, + linkedPR: draft.linkedPR, + hostTarget: draft.hostTarget, + attachmentCount: files.length, + status: "creating", + error: null, + workspaceId: null, + warnings: [], + createdAt: new Date(), + }); - closeAndResetDraft(); - void navigate({ to: `/pending/${pendingId}` as string }); - }, [ - attachments, - closeAndResetDraft, - collections, - draft, - navigate, - projectId, - ]); + closeAndResetDraft(); + void navigate({ to: `/pending/${pendingId}` as string }); + }, + [closeAndResetDraft, collections, draft, navigate, projectId], + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 84142a7c5d4..bf818eda570 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -112,9 +112,40 @@ const pendingLinkedPRSchema = z.object({ state: z.string(), }); +/** + * Transient dispatch intents written by the pending page after + * host-service.create resolves. Consumed by the V2 workspace page's + * useConsumePendingLaunch mount effect, then cleared. See + * apps/desktop/docs/V2_LAUNCH_CONTEXT.md "Dispatch architecture". + */ +const pendingTerminalLaunchSchema = z.object({ + command: z.string(), + name: z.string().optional(), + // Attachment filenames, already written to .superset/attachments/ + // by the pending page via workspaceTrpc.filesystem.writeFile. + attachmentNames: z.array(z.string()).default([]), +}); + +const pendingChatLaunchSchema = z.object({ + initialPrompt: z.string().optional(), + initialFiles: z + .array( + z.object({ + data: z.string(), + mediaType: z.string(), + filename: z.string().optional(), + }), + ) + .optional(), + model: z.string().optional(), + taskSlug: z.string().optional(), +}); + export type PendingHostTarget = z.infer<typeof pendingHostTargetSchema>; export type PendingLinkedIssue = z.infer<typeof pendingLinkedIssueSchema>; export type PendingLinkedPR = z.infer<typeof pendingLinkedPRSchema>; +export type PendingTerminalLaunch = z.infer<typeof pendingTerminalLaunchSchema>; +export type PendingChatLaunch = z.infer<typeof pendingChatLaunchSchema>; export const pendingWorkspaceSchema = z.object({ // Shared @@ -154,6 +185,11 @@ export const pendingWorkspaceSchema = z.object({ // fork + checkout (irrelevant for adopt — worktree already exists). runSetupScript: z.boolean().default(true), + + // Transient dispatch intents written after host-service.create resolves; + // consumed by the V2 workspace page on mount, then cleared to null. + terminalLaunch: pendingTerminalLaunchSchema.nullable().default(null), + chatLaunch: pendingChatLaunchSchema.nullable().default(null), }); export type PendingWorkspaceRow = z.infer<typeof pendingWorkspaceSchema>; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts index bcf42e46f64..e4d54b87cb4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts @@ -11,6 +11,8 @@ const BUILTIN_TERMINAL_PRESET: ResolvedAgentConfig = { promptCommand: "claude --print", promptTransport: "argv", taskPromptTemplate: "Task {{slug}}", + contextPromptTemplateSystem: "", + contextPromptTemplateUser: "", enabled: true, overriddenFields: [], }; @@ -24,6 +26,8 @@ const CUSTOM_TERMINAL_PRESET: ResolvedAgentConfig = { promptCommand: "team-agent --prompt", promptTransport: "argv", taskPromptTemplate: "Task {{slug}}", + contextPromptTemplateSystem: "", + contextPromptTemplateUser: "", enabled: true, overriddenFields: [], }; diff --git a/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts b/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts new file mode 100644 index 00000000000..4fd7729fdc6 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts @@ -0,0 +1,23 @@ +import type { AttachmentFile } from "../types"; + +export const attachmentLogsTxt: AttachmentFile = { + data: new TextEncoder().encode( + "2026-04-14 ERROR auth.ts:42 token decrypt failed\n", + ), + mediaType: "text/plain", + filename: "logs.txt", +}; + +export const attachmentScreenshotPng: AttachmentFile = { + // 1x1 transparent PNG + data: new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]), + mediaType: "image/png", + filename: "screenshot.png", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts b/apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts new file mode 100644 index 00000000000..45ae55962cc --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts @@ -0,0 +1,17 @@ +import type { GitHubIssueContent } from "../types"; + +export const githubIssueAuthMiddleware: GitHubIssueContent = { + number: 123, + url: "https://github.com/acme/repo/issues/123", + title: "Auth middleware stores tokens in plaintext", + body: "Legal flagged this. Sessions written to disk without encryption.", + slug: "auth-middleware-stores-tokens-in-plaintext", +}; + +export const githubIssueTokenRotation: GitHubIssueContent = { + number: 124, + url: "https://github.com/acme/repo/issues/124", + title: "Rotate session tokens on password change", + body: "Follow-up for #123.", + slug: "rotate-session-tokens-on-password-change", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts b/apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts new file mode 100644 index 00000000000..811f3d655ed --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts @@ -0,0 +1,9 @@ +import type { GitHubPullRequestContent } from "../types"; + +export const githubPrAuthRewrite: GitHubPullRequestContent = { + number: 200, + url: "https://github.com/acme/repo/pull/200", + title: "Rewrite auth middleware", + body: "Replaces plaintext token storage with encrypted KV.", + branch: "fix/auth-encryption", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/index.ts b/apps/desktop/src/shared/context/__fixtures__/index.ts new file mode 100644 index 00000000000..c50e05c0b50 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/index.ts @@ -0,0 +1,6 @@ +export * from "./attachment.logs-txt"; +export * from "./githubIssue.auth-middleware"; +export * from "./githubPr.auth-rewrite"; +export * from "./internalTask.refactor-auth"; +export * from "./launchContext.multi-source"; +export * from "./launchContext.prompt-only"; diff --git a/apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts b/apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts new file mode 100644 index 00000000000..928a5ce9997 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts @@ -0,0 +1,9 @@ +import type { InternalTaskContent } from "../types"; + +export const internalTaskRefactorAuth: InternalTaskContent = { + id: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth middleware", + description: + "Split session-token storage from request handling so we can encrypt at rest.", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts new file mode 100644 index 00000000000..13106bdd6d9 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -0,0 +1,119 @@ +import type { LaunchContext, LaunchSource } from "../types"; +import { + attachmentLogsTxt, + attachmentScreenshotPng, +} from "./attachment.logs-txt"; +import { + githubIssueAuthMiddleware, + githubIssueTokenRotation, +} from "./githubIssue.auth-middleware"; +import { githubPrAuthRewrite } from "./githubPr.auth-rewrite"; +import { internalTaskRefactorAuth } from "./internalTask.refactor-auth"; + +const sources: LaunchSource[] = [ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + { kind: "internal-task", id: internalTaskRefactorAuth.id }, + { kind: "github-issue", url: githubIssueAuthMiddleware.url }, + { kind: "github-issue", url: githubIssueTokenRotation.url }, + { kind: "github-pr", url: githubPrAuthRewrite.url }, + { kind: "attachment", file: attachmentLogsTxt }, + { kind: "attachment", file: attachmentScreenshotPng }, +]; + +export const launchContextMultiSource: LaunchContext = { + projectId: "project-1", + sources, + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + { + id: `task:${internalTaskRefactorAuth.id}`, + kind: "internal-task", + label: `Task ${internalTaskRefactorAuth.id} — ${internalTaskRefactorAuth.title}`, + content: [ + { + type: "text", + text: `# ${internalTaskRefactorAuth.title}\n\n${internalTaskRefactorAuth.description}`, + }, + ], + meta: { taskSlug: internalTaskRefactorAuth.slug }, + }, + { + id: `issue:${githubIssueAuthMiddleware.number}`, + kind: "github-issue", + label: `Issue #${githubIssueAuthMiddleware.number} — ${githubIssueAuthMiddleware.title}`, + content: [ + { + type: "text", + text: `# ${githubIssueAuthMiddleware.title}\n\n${githubIssueAuthMiddleware.body}`, + }, + ], + meta: { + url: githubIssueAuthMiddleware.url, + taskSlug: githubIssueAuthMiddleware.slug, + }, + }, + { + id: `issue:${githubIssueTokenRotation.number}`, + kind: "github-issue", + label: `Issue #${githubIssueTokenRotation.number} — ${githubIssueTokenRotation.title}`, + content: [ + { + type: "text", + text: `# ${githubIssueTokenRotation.title}\n\n${githubIssueTokenRotation.body}`, + }, + ], + meta: { + url: githubIssueTokenRotation.url, + taskSlug: githubIssueTokenRotation.slug, + }, + }, + { + id: `pr:${githubPrAuthRewrite.number}`, + kind: "github-pr", + label: `PR #${githubPrAuthRewrite.number} — ${githubPrAuthRewrite.title}`, + content: [ + { + type: "text", + text: `# ${githubPrAuthRewrite.title}\n\nBranch: \`${githubPrAuthRewrite.branch}\`\n\n${githubPrAuthRewrite.body}`, + }, + ], + meta: { url: githubPrAuthRewrite.url }, + }, + { + id: "attachment:logs.txt", + kind: "attachment", + label: "logs.txt", + content: [ + { + type: "file", + data: attachmentLogsTxt.data, + mediaType: attachmentLogsTxt.mediaType, + filename: attachmentLogsTxt.filename, + }, + ], + }, + { + id: "attachment:screenshot.png", + kind: "attachment", + label: "screenshot.png", + content: [ + { + type: "image", + data: attachmentScreenshotPng.data, + mediaType: attachmentScreenshotPng.mediaType, + }, + ], + }, + ], + failures: [], + taskSlug: internalTaskRefactorAuth.slug, + agent: { id: "claude", config: undefined }, +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts new file mode 100644 index 00000000000..c8b6ef4d0af --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts @@ -0,0 +1,24 @@ +import type { LaunchContext, LaunchSource } from "../types"; + +const sources: LaunchSource[] = [ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, +]; + +export const launchContextPromptOnly: LaunchContext = { + projectId: "project-1", + sources, + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + ], + failures: [], + taskSlug: undefined, + agent: { id: "claude", config: undefined }, +}; diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts new file mode 100644 index 00000000000..ad514845836 --- /dev/null +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, test } from "bun:test"; +import { + indexResolvedAgentConfigs, + type ResolvedAgentConfig, + resolveAgentConfigs, +} from "shared/utils/agent-settings"; +import { launchContextMultiSource } from "./__fixtures__"; +import { buildLaunchSpec } from "./buildLaunchSpec"; +import type { AttachmentFile, LaunchContext } from "./types"; + +function getConfig(id: string): ResolvedAgentConfig { + const configs = indexResolvedAgentConfigs(resolveAgentConfigs({})); + const config = configs.get(id as never); + if (!config) throw new Error(`agent not found: ${id}`); + return config; +} + +function baseCtx(overrides: Partial<LaunchContext> = {}): LaunchContext { + return { + projectId: "p", + sources: [], + sections: [], + failures: [], + agent: { id: "claude" }, + ...overrides, + }; +} + +const PNG_BYTES = new Uint8Array([137, 80, 78, 71]); +const TXT_ATTACHMENT: AttachmentFile = { + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "logs.txt", +}; + +describe("buildLaunchSpec", () => { + test("returns null when agent.id is 'none'", () => { + const spec = buildLaunchSpec(baseCtx({ agent: { id: "none" } }), undefined); + expect(spec).toBeNull(); + }); + + test("returns null when agentConfig is missing", () => { + const spec = buildLaunchSpec(baseCtx(), undefined); + expect(spec).toBeNull(); + }); + + test("agentId + taskSlug flow through", () => { + const spec = buildLaunchSpec( + baseCtx({ + taskSlug: "refactor-auth", + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "hello" }], + }, + ], + }), + getConfig("claude"), + ); + expect(spec?.agentId).toBe("claude"); + expect(spec?.taskSlug).toBe("refactor-auth"); + }); + + test("all builtin agents share the default markdown template (no XML)", () => { + const section = { + id: "user-prompt", + kind: "user-prompt" as const, + label: "Prompt", + content: [ + { type: "text" as const, text: "refactor the auth middleware" }, + ], + }; + const claudeSpec = buildLaunchSpec( + baseCtx({ sections: [section] }), + getConfig("claude"), + ); + const codexSpec = buildLaunchSpec( + baseCtx({ sections: [section], agent: { id: "codex" } }), + getConfig("codex"), + ); + const claudeText = (claudeSpec?.user[0] as { type: "text"; text: string }) + .text; + const codexText = (codexSpec?.user[0] as { type: "text"; text: string }) + .text; + expect(claudeText).toBe("refactor the auth middleware"); + expect(claudeText).toBe(codexText); + expect(claudeText).not.toContain("<user-request>"); + }); + + test("empty system template produces empty system content array", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "hi" }], + }, + ], + }), + getConfig("claude"), + ); + expect(spec?.system).toEqual([]); + }); + + test("issues section body is dropped into {{issues}} variable", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor" }], + }, + { + id: "issue:123", + kind: "github-issue", + label: "Issue #123 — Auth", + content: [ + { + type: "text", + text: "# Auth\n\nLegal flagged this.", + }, + ], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain("refactor"); + expect(userText).toContain("# Auth"); + expect(userText).toContain("Legal flagged this."); + }); + + test("multiple tasks of the same kind join with a separator", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "plan" }], + }, + { + id: "task:T-1", + kind: "internal-task", + label: "Task T-1", + content: [{ type: "text", text: "# T-1\n\nOne." }], + }, + { + id: "task:T-2", + kind: "internal-task", + label: "Task T-2", + content: [{ type: "text", text: "# T-2\n\nTwo." }], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain("# T-1"); + expect(userText).toContain("# T-2"); + expect(userText.indexOf("T-1")).toBeLessThan(userText.indexOf("T-2")); + }); + + test("attachment sections are listed in {{attachments}} + file parts collected separately", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "fix the bug" }], + }, + { + id: "attachment:logs.txt", + kind: "attachment", + label: "logs.txt", + content: [ + { + type: "file", + data: TXT_ATTACHMENT.data, + mediaType: TXT_ATTACHMENT.mediaType, + filename: TXT_ATTACHMENT.filename, + }, + ], + }, + { + id: "attachment:screen.png", + kind: "attachment", + label: "screen.png", + content: [ + { type: "image", data: PNG_BYTES, mediaType: "image/png" }, + ], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain(".superset/attachments/logs.txt"); + expect(userText).toContain(".superset/attachments/screen.png"); + expect(spec?.attachments).toHaveLength(2); + expect(spec?.attachments[0]?.type).toBe("file"); + expect(spec?.attachments[1]?.type).toBe("image"); + }); + + test("inline non-text parts from user-prompt stay inline in spec.user", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [ + { type: "text", text: "see this:" }, + { type: "image", data: PNG_BYTES, mediaType: "image/png" }, + { type: "text", text: "and fix" }, + ], + }, + ], + }), + getConfig("codex"), + ); + + // Inline order preserved: text, image, text reach the agent in sequence + // so chat agents render the image between the two text chunks. + expect(spec?.user).toHaveLength(3); + expect(spec?.user[0]).toEqual({ type: "text", text: "see this:" }); + expect(spec?.user[1]).toEqual({ + type: "image", + data: PNG_BYTES, + mediaType: "image/png", + }); + expect(spec?.user[2]?.type).toBe("text"); + expect((spec?.user[2] as { type: "text"; text: string }).text).toContain( + "and fix", + ); + + // Explicit attachment-kind sections land in spec.attachments; inline + // user-prompt parts do not. + expect(spec?.attachments).toEqual([]); + }); + + test("inline non-text parts still appear in the {{attachments}} list for CLIs", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [ + { type: "text", text: "check this log:" }, + { + type: "file", + data: new Uint8Array([1, 2]), + mediaType: "text/plain", + filename: "trace.log", + }, + ], + }, + ], + }), + getConfig("codex"), + ); + const lastText = ( + spec?.user[spec.user.length - 1] as { type: "text"; text: string } + )?.text; + // Attachments list renders after the inline parts so a CLI agent + // reading just the flattened text still has the file path reference. + expect(lastText).toContain(".superset/attachments/trace.log"); + }); + + test("empty userPrompt still renders system = [] and drops empty user template cleanly", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "issue:1", + kind: "github-issue", + label: "Issue #1", + content: [{ type: "text", text: "# Issue\n\nbody" }], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain("# Issue"); + expect(userText).not.toMatch(/^\n/); + expect(userText).not.toMatch(/\n$/); + }); + + test("no sections at all yields empty system + empty user", () => { + const spec = buildLaunchSpec(baseCtx(), getConfig("codex")); + expect(spec?.system).toEqual([]); + expect(spec?.user).toEqual([]); + expect(spec?.attachments).toEqual([]); + }); + + test("canonical multi-source fixture → claude XML spec (snapshot)", () => { + const spec = buildLaunchSpec(launchContextMultiSource, getConfig("claude")); + expect({ + agentId: spec?.agentId, + system: spec?.system, + userText: (spec?.user[0] as { type: "text"; text: string })?.text, + attachmentKinds: spec?.attachments.map((p) => p.type), + taskSlug: spec?.taskSlug, + }).toMatchInlineSnapshot(` +{ + "agentId": "claude", + "attachmentKinds": [ + "file", + "image", + ], + "system": [], + "taskSlug": "refactor-auth", + "userText": +"refactor the auth middleware + +# Refactor auth middleware + +Split session-token storage from request handling so we can encrypt at rest. + +# Auth middleware stores tokens in plaintext + +Legal flagged this. Sessions written to disk without encryption. + +# Rotate session tokens on password change + +Follow-up for #123. + +# Rewrite auth middleware + +Branch: \`fix/auth-encryption\` + +Replaces plaintext token storage with encrypted KV. + +# Attached files + +The user attached these files alongside the prompt. They've been +written into the worktree at \`.superset/attachments/\`. Read them +to understand the request — they're part of the task, not +optional reference. + +- .superset/attachments/logs.txt +- .superset/attachments/screenshot.png" +, +} +`); + }); + + test("canonical multi-source fixture → codex markdown spec (snapshot)", () => { + const spec = buildLaunchSpec(launchContextMultiSource, getConfig("codex")); + expect({ + agentId: spec?.agentId, + userText: (spec?.user[0] as { type: "text"; text: string })?.text, + attachmentKinds: spec?.attachments.map((p) => p.type), + taskSlug: spec?.taskSlug, + }).toMatchInlineSnapshot(` +{ + "agentId": "claude", + "attachmentKinds": [ + "file", + "image", + ], + "taskSlug": "refactor-auth", + "userText": +"refactor the auth middleware + +# Refactor auth middleware + +Split session-token storage from request handling so we can encrypt at rest. + +# Auth middleware stores tokens in plaintext + +Legal flagged this. Sessions written to disk without encryption. + +# Rotate session tokens on password change + +Follow-up for #123. + +# Rewrite auth middleware + +Branch: \`fix/auth-encryption\` + +Replaces plaintext token storage with encrypted KV. + +# Attached files + +The user attached these files alongside the prompt. They've been +written into the worktree at \`.superset/attachments/\`. Read them +to understand the request — they're part of the task, not +optional reference. + +- .superset/attachments/logs.txt +- .superset/attachments/screenshot.png" +, +} +`); + }); + + test("agent-side template override replaces the user template", () => { + const configs = resolveAgentConfigs({ + overrideEnvelope: { + version: 1, + presets: [ + { + id: "claude", + contextPromptTemplateUser: "CUSTOM {{userPrompt}} END", + }, + ], + }, + }); + const claude = indexResolvedAgentConfigs(configs).get("claude"); + if (!claude) throw new Error("claude missing"); + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "hi" }], + }, + ], + }), + claude, + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toBe("CUSTOM hi END"); + }); +}); diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.ts b/apps/desktop/src/shared/context/buildLaunchSpec.ts new file mode 100644 index 00000000000..95762884dcd --- /dev/null +++ b/apps/desktop/src/shared/context/buildLaunchSpec.ts @@ -0,0 +1,236 @@ +import { renderPromptTemplate } from "@superset/shared/agent-prompt-template"; +import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; +import type { + AgentLaunchSpec, + ContentPart, + ContextSection, + LaunchContext, + LaunchSourceKind, +} from "./types"; + +const USER_PROMPT_PLACEHOLDER_RE = /\{\{\s*userPrompt\s*\}\}/; +const PLACEHOLDER_RE = /\{\{\s*([^}]+?)\s*\}\}/g; + +/** + * Build a V2-native AgentLaunchSpec from a resolved LaunchContext and the + * selected agent's config. + * + * - Returns null for the "none" agent or when config is missing (matches + * V1's buildPromptAgentLaunchRequest semantics). + * - The user-prompt section's content parts are spliced into spec.user + * *in place* at the template's {{userPrompt}} position — so a + * text + image + text rich-editor prompt keeps its inline ordering for + * chat agents. Terminal adapters flatten later (step 7) by rendering + * file/image parts as markdown refs at their inline position. + * - Text from {{tasks}}/{{issues}}/{{prs}}/{{attachments}} renders as + * surrounding text parts. + * - spec.attachments carries only *explicit* attachment-kind sections + * (dragged/dropped files). Inline file/image parts from the user + * prompt stay inline in spec.user. + */ +export function buildLaunchSpec( + ctx: LaunchContext, + agentConfig: ResolvedAgentConfig | undefined, +): AgentLaunchSpec | null { + if (ctx.agent.id === "none" || !agentConfig) return null; + + const nonUserVariables = buildNonUserPromptVariables(ctx.sections); + const userPromptParts = collectUserPromptContent(ctx.sections); + + const system = renderScalarTemplate( + agentConfig.contextPromptTemplateSystem, + nonUserVariables, + ); + const user = renderUserTemplate( + agentConfig.contextPromptTemplateUser, + userPromptParts, + nonUserVariables, + ); + + return { + agentId: ctx.agent.id, + system, + user, + attachments: collectExplicitAttachments(ctx.sections), + taskSlug: ctx.taskSlug, + }; +} + +function renderScalarTemplate( + template: string, + variables: Record<string, string>, +): ContentPart[] { + const text = renderPromptTemplate(template, { ...variables, userPrompt: "" }); + return text ? [{ type: "text", text }] : []; +} + +/** + * Render the user template as a ContentPart sequence. The template is + * split on {{userPrompt}}; the text before/after has its other + * placeholders substituted raw (no trim / no line collapse) so + * whitespace around {{userPrompt}} is preserved. The user-prompt + * section's content parts are spliced in at that position. A final + * pass collapses excess blank lines and trims document boundaries. + */ +function renderUserTemplate( + template: string, + userPromptParts: ContentPart[], + nonUserVariables: Record<string, string>, +): ContentPart[] { + // Whitespace-tolerant split on {{userPrompt}} / {{ userPrompt }} / etc. + const match = template.match(USER_PROMPT_PLACEHOLDER_RE); + const splitIndex = match?.index ?? -1; + const [beforeRaw, afterRaw] = + splitIndex === -1 || !match + ? ["", template] + : [ + template.slice(0, splitIndex), + template.slice(splitIndex + match[0].length), + ]; + + const beforeText = substituteVariables(beforeRaw, nonUserVariables); + const afterText = substituteVariables(afterRaw, nonUserVariables); + + const parts: ContentPart[] = []; + if (splitIndex === -1) { + // Template doesn't reference userPrompt: prepend parts so they + // still reach the agent (rare; misconfigured template). + parts.push(...userPromptParts); + if (afterText) parts.push({ type: "text", text: afterText }); + } else { + if (beforeText) parts.push({ type: "text", text: beforeText }); + parts.push(...userPromptParts); + if (afterText) parts.push({ type: "text", text: afterText }); + } + + return finalize(mergeAdjacentTextParts(parts)); +} + +/** + * Placeholder substitution with no trim / no newline collapse — for + * template halves where surrounding whitespace is structural. + */ +function substituteVariables( + template: string, + variables: Record<string, string>, +): string { + return template.replace(PLACEHOLDER_RE, (match, rawKey: string) => { + const key = rawKey.trim(); + return Object.hasOwn(variables, key) ? variables[key] : match; + }); +} + +/** + * Normalize runs of 3+ newlines to 2 inside each text part, trim + * leading whitespace on the first text part, trim trailing whitespace + * on the last text part. Drops text parts that become empty. + */ +function finalize(parts: ContentPart[]): ContentPart[] { + const out: ContentPart[] = []; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!part) continue; + if (part.type !== "text") { + out.push(part); + continue; + } + let text = part.text.replace(/\n{3,}/g, "\n\n"); + if (i === 0) text = text.replace(/^\s+/, ""); + if (i === parts.length - 1) text = text.replace(/\s+$/, ""); + if (text.length === 0) continue; + out.push({ type: "text", text }); + } + return out; +} + +function buildNonUserPromptVariables( + sections: ContextSection[], +): Record<string, string> { + return { + tasks: renderKindBlock(sectionsOfKind(sections, "internal-task")), + issues: renderKindBlock(sectionsOfKind(sections, "github-issue")), + prs: renderKindBlock(sectionsOfKind(sections, "github-pr")), + attachments: renderAttachmentsList(sections), + }; +} + +function sectionsOfKind( + sections: ContextSection[], + kind: LaunchSourceKind, +): ContextSection[] { + return sections.filter((s) => s.kind === kind); +} + +function textPartsOf(section: ContextSection): string[] { + return section.content + .filter( + (p): p is Extract<ContentPart, { type: "text" }> => p.type === "text", + ) + .map((p) => p.text); +} + +function collectUserPromptContent(sections: ContextSection[]): ContentPart[] { + return sectionsOfKind(sections, "user-prompt").flatMap((s) => s.content); +} + +function renderKindBlock(sections: ContextSection[]): string { + if (sections.length === 0) return ""; + return sections + .map((s) => textPartsOf(s).join("\n\n")) + .filter(Boolean) + .join("\n\n"); +} + +/** + * Attachments block covers (a) explicit attachment-kind sections and + * (b) inline non-text parts from the user prompt — so CLI agents + * reading just the prompt text still see a reference to every + * file/image, with a framing header cueing the agent to actually read + * them rather than treating them as passive metadata. + */ +function renderAttachmentsList(sections: ContextSection[]): string { + const refs: string[] = []; + for (const section of sectionsOfKind(sections, "attachment")) { + refs.push(`- .superset/attachments/${section.label}`); + } + for (const section of sectionsOfKind(sections, "user-prompt")) { + for (const part of section.content) { + if (part.type === "text") continue; + const label = part.type === "file" ? part.filename : undefined; + refs.push(`- .superset/attachments/${label ?? "inline-attachment"}`); + } + } + if (refs.length === 0) return ""; + return [ + "# Attached files", + "", + "The user attached these files alongside the prompt. They've been", + "written into the worktree at `.superset/attachments/`. Read them", + "to understand the request — they're part of the task, not", + "optional reference.", + "", + refs.join("\n"), + ].join("\n"); +} + +function collectExplicitAttachments(sections: ContextSection[]): ContentPart[] { + return sectionsOfKind(sections, "attachment").flatMap((s) => + s.content.filter((p) => p.type !== "text"), + ); +} + +function mergeAdjacentTextParts(parts: ContentPart[]): ContentPart[] { + const merged: ContentPart[] = []; + for (const part of parts) { + const last = merged[merged.length - 1]; + if (part.type === "text" && last?.type === "text") { + merged[merged.length - 1] = { + type: "text", + text: last.text + part.text, + }; + } else { + merged.push(part); + } + } + return merged; +} diff --git a/apps/desktop/src/shared/context/composer.integration.test.ts b/apps/desktop/src/shared/context/composer.integration.test.ts new file mode 100644 index 00000000000..9cc5bfd34e4 --- /dev/null +++ b/apps/desktop/src/shared/context/composer.integration.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { + attachmentLogsTxt, + githubIssueAuthMiddleware, + githubIssueTokenRotation, + githubPrAuthRewrite, + internalTaskRefactorAuth, +} from "./__fixtures__"; +import { buildLaunchContext } from "./composer"; +import { defaultContributorRegistry } from "./contributors"; +import type { ResolveCtx } from "./types"; + +const resolveCtx: ResolveCtx = { + projectId: "project-1", + signal: new AbortController().signal, + fetchIssue: async (url) => { + if (url === githubIssueAuthMiddleware.url) return githubIssueAuthMiddleware; + if (url === githubIssueTokenRotation.url) return githubIssueTokenRotation; + throw Object.assign(new Error("not found"), { status: 404 }); + }, + fetchPullRequest: async (url) => { + if (url === githubPrAuthRewrite.url) return githubPrAuthRewrite; + throw Object.assign(new Error("not found"), { status: 404 }); + }, + fetchInternalTask: async (id) => { + if (id === internalTaskRefactorAuth.id) return internalTaskRefactorAuth; + throw Object.assign(new Error("not found"), { status: 404 }); + }, +}; + +describe("composer + default registry (integration)", () => { + test("composes a multi-source launch end-to-end", async () => { + const ctx = await buildLaunchContext( + { + projectId: "project-1", + sources: [ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + { kind: "internal-task", id: internalTaskRefactorAuth.id }, + { kind: "github-issue", url: githubIssueAuthMiddleware.url }, + { kind: "github-issue", url: githubIssueTokenRotation.url }, + { kind: "github-pr", url: githubPrAuthRewrite.url }, + { kind: "attachment", file: attachmentLogsTxt }, + ], + agent: { id: "claude" }, + }, + { contributors: defaultContributorRegistry, resolveCtx }, + ); + + expect(ctx.failures).toEqual([]); + expect(ctx.sections.map((s) => s.kind)).toEqual([ + "user-prompt", + "internal-task", + "github-issue", + "github-issue", + "github-pr", + "attachment", + ]); + expect(ctx.taskSlug).toBe(internalTaskRefactorAuth.slug); + }); + + test("missing issue is a non-fatal null (not a failure)", async () => { + const ctx = await buildLaunchContext( + { + projectId: "project-1", + sources: [ + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + { + kind: "github-issue", + url: "https://github.com/acme/repo/issues/99999", + }, + ], + agent: { id: "none" }, + }, + { contributors: defaultContributorRegistry, resolveCtx }, + ); + expect(ctx.sections.map((s) => s.kind)).toEqual(["user-prompt"]); + expect(ctx.failures).toEqual([]); + }); +}); diff --git a/apps/desktop/src/shared/context/composer.test.ts b/apps/desktop/src/shared/context/composer.test.ts new file mode 100644 index 00000000000..7184fbc4799 --- /dev/null +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from "bun:test"; +import { buildLaunchContext, CONTRIBUTOR_TIMEOUT_MS } from "./composer"; +import type { + ContextContributor, + ContextSection, + ContributorRegistry, + LaunchSource, + ResolveCtx, +} from "./types"; + +function makeContributor<K extends LaunchSource["kind"]>( + kind: K, + resolver: ( + source: Extract<LaunchSource, { kind: K }>, + ) => Promise<ContextSection | null>, +): ContextContributor<Extract<LaunchSource, { kind: K }>> { + return { + kind, + displayName: kind, + description: kind, + requiresQuery: false, + resolve: (source) => resolver(source), + } as ContextContributor<Extract<LaunchSource, { kind: K }>>; +} + +function registry( + overrides: Partial<{ + [K in LaunchSource["kind"]]: ContextContributor< + Extract<LaunchSource, { kind: K }> + >; + }>, +): ContributorRegistry { + const defaults: ContributorRegistry = { + "user-prompt": makeContributor("user-prompt", async (s) => ({ + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: s.content, + })), + "github-issue": makeContributor("github-issue", async (s) => ({ + id: `issue:${s.url}`, + kind: "github-issue", + label: s.url, + content: [{ type: "text", text: s.url }], + meta: { url: s.url, taskSlug: `slug-${s.url}` }, + })), + "github-pr": makeContributor("github-pr", async (s) => ({ + id: `pr:${s.url}`, + kind: "github-pr", + label: s.url, + content: [{ type: "text", text: s.url }], + meta: { url: s.url }, + })), + "internal-task": makeContributor("internal-task", async (s) => ({ + id: `task:${s.id}`, + kind: "internal-task", + label: s.id, + content: [{ type: "text", text: s.id }], + meta: { taskSlug: `task-slug-${s.id}` }, + })), + attachment: makeContributor("attachment", async (s) => ({ + id: `attachment:${s.file.filename ?? "unnamed"}`, + kind: "attachment", + label: s.file.filename ?? "attachment", + content: [ + { + type: "file", + data: s.file.data, + mediaType: s.file.mediaType, + filename: s.file.filename, + }, + ], + })), + }; + + return { ...defaults, ...overrides }; +} + +const resolveCtx: ResolveCtx = { + projectId: "project-1", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("not used in tests"); + }, + fetchPullRequest: async () => { + throw new Error("not used in tests"); + }, + fetchInternalTask: async () => { + throw new Error("not used in tests"); + }, +}; + +describe("buildLaunchContext", () => { + test("empty sources produce empty sections", async () => { + const ctx = await buildLaunchContext( + { projectId: "p", sources: [], agent: { id: "none" } }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections).toEqual([]); + expect(ctx.failures).toEqual([]); + expect(ctx.taskSlug).toBeUndefined(); + }); + + test("dedups github-issue sources by url before dispatch", async () => { + let calls = 0; + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "github-issue", url: "https://x/issues/1" }, // dup + ], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor("github-issue", async (s) => { + calls++; + return { + id: `issue:${s.url}`, + kind: "github-issue", + label: s.url, + content: [{ type: "text", text: s.url }], + }; + }), + }), + resolveCtx, + }, + ); + expect(calls).toBe(1); + expect(ctx.sections).toHaveLength(1); + }); + + test("preserves input order within a kind", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/2" }, + { kind: "github-issue", url: "https://x/issues/1" }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections.map((s) => s.id)).toEqual([ + "issue:https://x/issues/2", + "issue:https://x/issues/1", + ]); + }); + + test("applies default kind group order across sections", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-pr", url: "https://x/pull/1" }, + { + kind: "attachment", + file: { + data: new Uint8Array([0]), + mediaType: "text/plain", + filename: "a.txt", + }, + }, + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "internal-task", id: "T-1" }, + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections.map((s) => s.kind)).toEqual([ + "user-prompt", + "internal-task", + "github-issue", + "github-pr", + "attachment", + ]); + }); + + test("taskSlug: first internal-task wins", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "internal-task", id: "T-1" }, + { kind: "internal-task", id: "T-2" }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.taskSlug).toBe("task-slug-T-1"); + }); + + test("taskSlug falls back to first github-issue when no task", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/2" }, + { kind: "github-issue", url: "https://x/issues/1" }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.taskSlug).toBe("slug-https://x/issues/2"); + }); + + test("taskSlug undefined when no task or issue", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.taskSlug).toBeUndefined(); + }); + + test("per-source failure populates failures[] and launch continues", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "github-issue", url: "https://x/issues/2" }, + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + ], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor("github-issue", async (s) => { + if (s.url.endsWith("/2")) throw new Error("boom"); + return { + id: `issue:${s.url}`, + kind: "github-issue", + label: s.url, + content: [{ type: "text", text: s.url }], + }; + }), + }), + resolveCtx, + }, + ); + expect(ctx.sections).toHaveLength(2); // issue 1 + prompt + expect(ctx.failures).toHaveLength(1); + expect(ctx.failures[0]?.error).toBe("boom"); + }); + + test("contributor returning null is dropped silently (not a failure)", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [{ kind: "github-issue", url: "https://x/issues/1" }], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor("github-issue", async () => null), + }), + resolveCtx, + }, + ); + expect(ctx.sections).toEqual([]); + expect(ctx.failures).toEqual([]); + }); + + test("contributor exceeding timeout is a failure", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [{ kind: "github-issue", url: "https://x/issues/1" }], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor( + "github-issue", + () => new Promise(() => {}), // never resolves + ), + }), + resolveCtx, + timeoutMs: 10, + }, + ); + expect(ctx.sections).toEqual([]); + expect(ctx.failures).toHaveLength(1); + expect(ctx.failures[0]?.error).toMatch(/timeout/i); + }); + + test("default timeout is 10s", () => { + expect(CONTRIBUTOR_TIMEOUT_MS).toBe(10_000); + }); + + test("attachments are not deduped even without filename", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { + kind: "attachment", + file: { data: new Uint8Array([1]), mediaType: "text/plain" }, + }, + { + kind: "attachment", + file: { data: new Uint8Array([2]), mediaType: "text/plain" }, + }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections).toHaveLength(2); + }); +}); diff --git a/apps/desktop/src/shared/context/composer.ts b/apps/desktop/src/shared/context/composer.ts new file mode 100644 index 00000000000..17de2ec10a0 --- /dev/null +++ b/apps/desktop/src/shared/context/composer.ts @@ -0,0 +1,159 @@ +import type { + BuildLaunchContextInputs, + ContextSection, + ContributorRegistry, + LaunchContext, + LaunchSource, + LaunchSourceKind, + ResolveCtx, +} from "./types"; + +export const CONTRIBUTOR_TIMEOUT_MS = 10_000; + +/** + * Order sections are grouped into when no consumer overrides. Matches the + * rendering order used by agent prompt templates. + */ +const KIND_ORDER: readonly LaunchSourceKind[] = [ + "user-prompt", + "internal-task", + "github-issue", + "github-pr", + "attachment", +] as const; + +export interface BuildLaunchContextDeps { + contributors: ContributorRegistry; + resolveCtx: ResolveCtx; + timeoutMs?: number; +} + +export async function buildLaunchContext( + inputs: BuildLaunchContextInputs, + deps: BuildLaunchContextDeps, +): Promise<LaunchContext> { + const timeoutMs = deps.timeoutMs ?? CONTRIBUTOR_TIMEOUT_MS; + const deduped = dedupeSources(inputs.sources); + const resolutions = await Promise.all( + deduped.map((source) => + resolveOne(source, deps.contributors, deps.resolveCtx, timeoutMs), + ), + ); + + const sections: ContextSection[] = []; + const failures: LaunchContext["failures"] = []; + for (let i = 0; i < deduped.length; i++) { + const result = resolutions[i]; + const source = deduped[i]; + if (!result || !source) continue; + if (result.kind === "section" && result.section) { + sections.push(result.section); + } else if (result.kind === "error") { + failures.push({ source, error: result.error }); + } + } + + sections.sort((a, b) => kindRank(a.kind) - kindRank(b.kind)); + + return { + projectId: inputs.projectId, + sources: deduped, + sections, + failures, + taskSlug: deriveTaskSlug(sections), + agent: inputs.agent, + }; +} + +type ResolveResult = + | { kind: "section"; section: ContextSection | null } + | { kind: "error"; error: string }; + +async function resolveOne( + source: LaunchSource, + contributors: ContributorRegistry, + resolveCtx: ResolveCtx, + timeoutMs: number, +): Promise<ResolveResult> { + const contributor = contributors[source.kind] as + | ContributorRegistry[LaunchSourceKind] + | undefined; + if (!contributor) { + return { kind: "error", error: `No contributor for kind ${source.kind}` }; + } + + try { + const section = await withTimeout( + // biome-ignore lint/suspicious/noExplicitAny: registry dispatch is verified by discriminated kind above + contributor.resolve(source as any, resolveCtx), + timeoutMs, + ); + return { kind: "section", section }; + } catch (err) { + return { + kind: "error", + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { + let timer: ReturnType<typeof setTimeout> | undefined; + const timeout = new Promise<never>((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Contributor timeout after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }) as Promise<T>; +} + +function kindRank(kind: LaunchSourceKind): number { + const idx = KIND_ORDER.indexOf(kind); + return idx === -1 ? KIND_ORDER.length : idx; +} + +/** + * Kind-specific identity: URL/id-based kinds dedup on their identifier. + * Attachments never dedup — users dragging N files mean N files. + */ +function sourceIdentity(source: LaunchSource): string | null { + switch (source.kind) { + case "user-prompt": + return "user-prompt"; + case "github-issue": + return `github-issue:${source.url}`; + case "github-pr": + return `github-pr:${source.url}`; + case "internal-task": + return `internal-task:${source.id}`; + case "attachment": + return null; // never dedup + } +} + +function dedupeSources(sources: LaunchSource[]): LaunchSource[] { + const seen = new Set<string>(); + const out: LaunchSource[] = []; + for (const source of sources) { + const id = sourceIdentity(source); + if (id === null) { + out.push(source); + continue; + } + if (seen.has(id)) continue; + seen.add(id); + out.push(source); + } + return out; +} + +function deriveTaskSlug(sections: ContextSection[]): string | undefined { + const firstTask = sections.find((s) => s.kind === "internal-task"); + if (firstTask?.meta?.taskSlug) return firstTask.meta.taskSlug; + const firstIssue = sections.find((s) => s.kind === "github-issue"); + if (firstIssue?.meta?.taskSlug) return firstIssue.meta.taskSlug; + return undefined; +} diff --git a/apps/desktop/src/shared/context/contributors/attachment.test.ts b/apps/desktop/src/shared/context/contributors/attachment.test.ts new file mode 100644 index 00000000000..8ae55713420 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/attachment.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolveCtx } from "../types"; +import { attachmentContributor } from "./attachment"; + +const resolveCtx = {} as ResolveCtx; + +describe("attachmentContributor", () => { + test("metadata is set", () => { + expect(attachmentContributor.kind).toBe("attachment"); + expect(attachmentContributor.requiresQuery).toBe(false); + }); + + test("resolves a text/plain attachment to a file ContentPart", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "notes.txt", + }, + }, + resolveCtx, + ); + expect(section?.kind).toBe("attachment"); + expect(section?.label).toBe("notes.txt"); + expect(section?.content).toEqual([ + { + type: "file", + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "notes.txt", + }, + ]); + }); + + test("resolves an image to an image ContentPart", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + filename: "screen.png", + }, + }, + resolveCtx, + ); + expect(section?.content).toEqual([ + { + type: "image", + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + }, + ]); + }); + + test("unnamed attachment gets a fallback label and stable id", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([9]), + mediaType: "application/octet-stream", + }, + }, + resolveCtx, + ); + expect(section?.id).toBe("attachment:unnamed"); + expect(section?.label).toBe("attachment"); + }); + + test("id uses filename when present", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([1]), + mediaType: "text/plain", + filename: "logs.txt", + }, + }, + resolveCtx, + ); + expect(section?.id).toBe("attachment:logs.txt"); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/attachment.ts b/apps/desktop/src/shared/context/contributors/attachment.ts new file mode 100644 index 00000000000..ef820cf6e93 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/attachment.ts @@ -0,0 +1,29 @@ +import type { AttachmentFile, ContentPart, ContextContributor } from "../types"; + +export const attachmentContributor: ContextContributor<{ + kind: "attachment"; + file: AttachmentFile; +}> = { + kind: "attachment", + displayName: "Attachment", + description: "A file or image uploaded by the user.", + requiresQuery: false, + async resolve(source) { + const { file } = source; + const part: ContentPart = file.mediaType.startsWith("image/") + ? { type: "image", data: file.data, mediaType: file.mediaType } + : { + type: "file", + data: file.data, + mediaType: file.mediaType, + filename: file.filename, + }; + + return { + id: `attachment:${file.filename ?? "unnamed"}`, + kind: "attachment", + label: file.filename ?? "attachment", + content: [part], + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts new file mode 100644 index 00000000000..e632f202558 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHubIssueContent, ResolveCtx } from "../types"; +import { githubIssueContributor } from "./githubIssue"; + +function makeCtx( + fetchIssue: (url: string) => Promise<GitHubIssueContent>, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue, + fetchPullRequest: async () => { + throw new Error("unused"); + }, + fetchInternalTask: async () => { + throw new Error("unused"); + }, + }; +} + +const ISSUE: GitHubIssueContent = { + number: 123, + url: "https://github.com/acme/repo/issues/123", + title: "Auth stores tokens in plaintext", + body: "Legal flagged this.", + slug: "auth-stores-tokens-in-plaintext", +}; + +describe("githubIssueContributor", () => { + test("metadata", () => { + expect(githubIssueContributor.kind).toBe("github-issue"); + expect(githubIssueContributor.requiresQuery).toBe(true); + }); + + test("resolves to a section with explicit kind + number in header", async () => { + const section = await githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => ISSUE), + ); + expect(section?.id).toBe(`issue:${ISSUE.number}`); + expect(section?.label).toBe(`Issue #${ISSUE.number} — ${ISSUE.title}`); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# GitHub Issue #${ISSUE.number} — ${ISSUE.title}`); + expect(text).toContain(ISSUE.body); + expect(section?.meta).toEqual({ url: ISSUE.url, taskSlug: ISSUE.slug }); + }); + + test("returns null on fetch 404 (non-fatal)", async () => { + const section = await githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => { + throw Object.assign(new Error("not found"), { status: 404 }); + }), + ); + expect(section).toBeNull(); + }); + + test("propagates non-404 errors", async () => { + await expect( + githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => { + throw new Error("network"); + }), + ), + ).rejects.toThrow("network"); + }); + + test("omits body block when empty", async () => { + const section = await githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => ({ ...ISSUE, body: "" })), + ); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toBe(`# GitHub Issue #${ISSUE.number} — ${ISSUE.title}`); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.ts b/apps/desktop/src/shared/context/contributors/githubIssue.ts new file mode 100644 index 00000000000..3a509c29974 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubIssue.ts @@ -0,0 +1,40 @@ +import type { ContextContributor, GitHubIssueContent } from "../types"; + +function isNotFound(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: number }).status === 404 + ); +} + +export const githubIssueContributor: ContextContributor<{ + kind: "github-issue"; + url: string; +}> = { + kind: "github-issue", + displayName: "GitHub Issue", + description: "Full issue body fetched and inlined as context.", + requiresQuery: true, + async resolve(source, ctx) { + let issue: GitHubIssueContent; + try { + issue = await ctx.fetchIssue(source.url); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const body = issue.body.trim(); + const heading = `# GitHub Issue #${issue.number} — ${issue.title}`; + const text = body ? `${heading}\n\n${body}` : heading; + return { + id: `issue:${issue.number}`, + kind: "github-issue", + label: `Issue #${issue.number} — ${issue.title}`, + content: [{ type: "text", text }], + meta: { url: issue.url, taskSlug: issue.slug }, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/githubPr.test.ts b/apps/desktop/src/shared/context/contributors/githubPr.test.ts new file mode 100644 index 00000000000..cd9f2fcf70c --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHubPullRequestContent, ResolveCtx } from "../types"; +import { githubPrContributor } from "./githubPr"; + +function makeCtx( + fetchPullRequest: (url: string) => Promise<GitHubPullRequestContent>, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("unused"); + }, + fetchPullRequest, + fetchInternalTask: async () => { + throw new Error("unused"); + }, + }; +} + +const PR: GitHubPullRequestContent = { + number: 200, + url: "https://github.com/acme/repo/pull/200", + title: "Rewrite auth middleware", + body: "Replaces plaintext token storage.", + branch: "fix/auth-encryption", +}; + +describe("githubPrContributor", () => { + test("metadata", () => { + expect(githubPrContributor.kind).toBe("github-pr"); + expect(githubPrContributor.requiresQuery).toBe(true); + }); + + test("resolves to a user section with title + body + branch meta", async () => { + const section = await githubPrContributor.resolve( + { kind: "github-pr", url: PR.url }, + makeCtx(async () => PR), + ); + expect(section?.id).toBe(`pr:${PR.number}`); + expect(section?.label).toBe(`PR #${PR.number} — ${PR.title}`); + expect(section?.meta).toEqual({ url: PR.url }); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# PR #${PR.number} — ${PR.title}`); + expect(text).toContain(`This PR is checked out`); + expect(text).toContain(PR.body); + }); + + test("returns null on 404", async () => { + const section = await githubPrContributor.resolve( + { kind: "github-pr", url: PR.url }, + makeCtx(async () => { + throw Object.assign(new Error("not found"), { status: 404 }); + }), + ); + expect(section).toBeNull(); + }); + + test("omits body block when empty", async () => { + const section = await githubPrContributor.resolve( + { kind: "github-pr", url: PR.url }, + makeCtx(async () => ({ ...PR, body: "" })), + ); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# PR #${PR.number} — ${PR.title}`); + expect(text).toContain("checked out"); + expect(text).not.toContain("Replaces"); // body not present + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/githubPr.ts b/apps/desktop/src/shared/context/contributors/githubPr.ts new file mode 100644 index 00000000000..9e23c8c04e2 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubPr.ts @@ -0,0 +1,50 @@ +import type { ContextContributor, GitHubPullRequestContent } from "../types"; + +function isNotFound(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: number }).status === 404 + ); +} + +export const githubPrContributor: ContextContributor<{ + kind: "github-pr"; + url: string; +}> = { + kind: "github-pr", + displayName: "GitHub Pull Request", + description: "Full PR metadata fetched and inlined as context.", + requiresQuery: true, + async resolve(source, ctx) { + let pr: GitHubPullRequestContent; + try { + pr = await ctx.fetchPullRequest(source.url); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const body = pr.body.trim(); + // When a workspace is created from a linked PR, the PR's head + // branch is checked out into the worktree. Tell the agent so + // it doesn't start a new branch or open another PR — commits + // here continue this PR's history. + const branchLine = pr.branch + ? `This PR is checked out in this workspace on branch \`${pr.branch}\`. Commits you make here will be added to this PR.` + : ""; + const headerParts = [`# PR #${pr.number} — ${pr.title}`, branchLine].filter( + Boolean, + ); + const header = headerParts.join("\n\n"); + const text = body ? `${header}\n\n${body}` : header; + return { + id: `pr:${pr.number}`, + kind: "github-pr", + label: `PR #${pr.number} — ${pr.title}`, + content: [{ type: "text", text }], + meta: { url: pr.url }, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/index.ts b/apps/desktop/src/shared/context/contributors/index.ts new file mode 100644 index 00000000000..ee9b02e42ca --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/index.ts @@ -0,0 +1,22 @@ +import type { ContributorRegistry } from "../types"; +import { attachmentContributor } from "./attachment"; +import { githubIssueContributor } from "./githubIssue"; +import { githubPrContributor } from "./githubPr"; +import { internalTaskContributor } from "./internalTask"; +import { userPromptContributor } from "./userPrompt"; + +export const defaultContributorRegistry: ContributorRegistry = { + "user-prompt": userPromptContributor, + attachment: attachmentContributor, + "github-issue": githubIssueContributor, + "github-pr": githubPrContributor, + "internal-task": internalTaskContributor, +}; + +export { + attachmentContributor, + githubIssueContributor, + githubPrContributor, + internalTaskContributor, + userPromptContributor, +}; diff --git a/apps/desktop/src/shared/context/contributors/internalTask.test.ts b/apps/desktop/src/shared/context/contributors/internalTask.test.ts new file mode 100644 index 00000000000..0651d5449ec --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/internalTask.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import type { InternalTaskContent, ResolveCtx } from "../types"; +import { internalTaskContributor } from "./internalTask"; + +function makeCtx( + fetchInternalTask: (id: string) => Promise<InternalTaskContent>, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("unused"); + }, + fetchPullRequest: async () => { + throw new Error("unused"); + }, + fetchInternalTask, + }; +} + +const TASK: InternalTaskContent = { + id: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth middleware", + description: "Split session-token storage from request handling.", +}; + +describe("internalTaskContributor", () => { + test("metadata", () => { + expect(internalTaskContributor.kind).toBe("internal-task"); + expect(internalTaskContributor.requiresQuery).toBe(true); + }); + + test("resolves to a section with explicit kind + id in header", async () => { + const section = await internalTaskContributor.resolve( + { kind: "internal-task", id: TASK.id }, + makeCtx(async () => TASK), + ); + expect(section?.id).toBe(`task:${TASK.id}`); + expect(section?.label).toBe(`Task ${TASK.id} — ${TASK.title}`); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# Task ${TASK.id} — ${TASK.title}`); + if (TASK.description) expect(text).toContain(TASK.description); + expect(section?.meta).toEqual({ taskSlug: TASK.slug }); + }); + + test("omits description when null", async () => { + const section = await internalTaskContributor.resolve( + { kind: "internal-task", id: TASK.id }, + makeCtx(async () => ({ ...TASK, description: null })), + ); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toBe(`# Task ${TASK.id} — ${TASK.title}`); + }); + + test("returns null on 404", async () => { + const section = await internalTaskContributor.resolve( + { kind: "internal-task", id: TASK.id }, + makeCtx(async () => { + throw Object.assign(new Error("not found"), { status: 404 }); + }), + ); + expect(section).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/internalTask.ts b/apps/desktop/src/shared/context/contributors/internalTask.ts new file mode 100644 index 00000000000..34304592a5a --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/internalTask.ts @@ -0,0 +1,40 @@ +import type { ContextContributor, InternalTaskContent } from "../types"; + +function isNotFound(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: number }).status === 404 + ); +} + +export const internalTaskContributor: ContextContributor<{ + kind: "internal-task"; + id: string; +}> = { + kind: "internal-task", + displayName: "Task", + description: "Internal task spec inlined as context.", + requiresQuery: true, + async resolve(source, ctx) { + let task: InternalTaskContent; + try { + task = await ctx.fetchInternalTask(source.id); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const description = task.description?.trim() ?? ""; + const heading = `# Task ${task.id} — ${task.title}`; + const text = description ? `${heading}\n\n${description}` : heading; + return { + id: `task:${task.id}`, + kind: "internal-task", + label: `Task ${task.id} — ${task.title}`, + content: [{ type: "text", text }], + meta: { taskSlug: task.slug }, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.test.ts b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts new file mode 100644 index 00000000000..b75c9dd1288 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolveCtx } from "../types"; +import { userPromptContributor } from "./userPrompt"; + +const resolveCtx = {} as ResolveCtx; // not used by this contributor + +describe("userPromptContributor", () => { + test("metadata is set", () => { + expect(userPromptContributor.kind).toBe("user-prompt"); + expect(userPromptContributor.displayName).toBeTruthy(); + expect(userPromptContributor.description).toBeTruthy(); + expect(userPromptContributor.requiresQuery).toBe(true); + }); + + test("resolves a single text part", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + resolveCtx, + ); + expect(section).toEqual({ + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }); + }); + + test("returns null for empty content", async () => { + const section = await userPromptContributor.resolve( + { kind: "user-prompt", content: [] }, + resolveCtx, + ); + expect(section).toBeNull(); + }); + + test("returns null when only whitespace text parts are present", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [ + { type: "text", text: " " }, + { type: "text", text: "\n\n" }, + ], + }, + resolveCtx, + ); + expect(section).toBeNull(); + }); + + test("trims surrounding whitespace on bookend text parts", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [{ type: "text", text: " hello " }], + }, + resolveCtx, + ); + expect(section?.content).toEqual([{ type: "text", text: "hello" }]); + }); + + test("preserves interleaved multimodal content (text + image + text)", async () => { + const imageBytes = new Uint8Array([1, 2, 3]); + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [ + { type: "text", text: "Reproduce this bug:" }, + { type: "image", data: imageBytes, mediaType: "image/png" }, + { type: "text", text: "with the attached logs." }, + ], + }, + resolveCtx, + ); + expect(section?.content).toEqual([ + { type: "text", text: "Reproduce this bug:" }, + { type: "image", data: imageBytes, mediaType: "image/png" }, + { type: "text", text: "with the attached logs." }, + ]); + }); + + test("drops empty text parts between non-empty ones", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "" }, + { type: "text", text: "second" }, + ], + }, + resolveCtx, + ); + expect(section?.content).toEqual([ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ]); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.ts b/apps/desktop/src/shared/context/contributors/userPrompt.ts new file mode 100644 index 00000000000..3234ea7615e --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/userPrompt.ts @@ -0,0 +1,64 @@ +import type { ContentPart, ContextContributor, LaunchSource } from "../types"; + +/** + * Convenience builder for plain-text prompts. Callers that already have a + * mixed ContentPart[] (rich editor output, dropped-in images) should pass + * that directly; this helper is just sugar for the common text-only case. + */ +export function userPromptFromText( + text: string, +): Extract<LaunchSource, { kind: "user-prompt" }> { + return { kind: "user-prompt", content: [{ type: "text", text }] }; +} + +/** + * Drop empty text parts and trim surrounding whitespace on text parts that + * bookend the content. File/image parts are kept as-is. + */ +function normalize(content: ContentPart[]): ContentPart[] { + const normalized: ContentPart[] = []; + for (const part of content) { + if (part.type === "text") { + const text = part.text; + if (!text.trim()) continue; // drop empty text parts entirely + normalized.push({ type: "text", text }); + } else { + normalized.push(part); + } + } + + // Trim whitespace on the first and last text parts so leading/trailing + // whitespace from editor markup doesn't leak through. + const first = normalized[0]; + if (first?.type === "text") { + normalized[0] = { type: "text", text: first.text.trimStart() }; + } + const last = normalized[normalized.length - 1]; + if (last?.type === "text") { + normalized[normalized.length - 1] = { + type: "text", + text: last.text.trimEnd(), + }; + } + return normalized; +} + +export const userPromptContributor: ContextContributor<{ + kind: "user-prompt"; + content: ContentPart[]; +}> = { + kind: "user-prompt", + displayName: "Prompt", + description: "The user's free-form prompt for this launch.", + requiresQuery: true, + async resolve(source) { + const content = normalize(source.content); + if (content.length === 0) return null; + return { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/types.ts b/apps/desktop/src/shared/context/types.ts new file mode 100644 index 00000000000..49a3a9b9bf6 --- /dev/null +++ b/apps/desktop/src/shared/context/types.ts @@ -0,0 +1,154 @@ +import type { AgentDefinitionId } from "@superset/shared/agent-catalog"; +import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; + +/** + * Discriminated union of every kind of source that can contribute context + * to a workspace launch. Extending this is the first step to adding a new + * source (e.g. Linear tickets, Notion pages): add a variant, add a + * contributor, register it. + */ +export type LaunchSource = + | { kind: "user-prompt"; content: ContentPart[] } + | { kind: "github-issue"; url: string } + | { kind: "github-pr"; url: string } + | { kind: "internal-task"; id: string } + | { kind: "attachment"; file: AttachmentFile }; + +export type LaunchSourceKind = LaunchSource["kind"]; + +/** + * An attachment carried through composition. Stored as raw bytes — not + * base64 — so we skip the 33% overhead internally. Base64 encoding happens + * only at the chat provider API boundary. + */ +export interface AttachmentFile { + data: Uint8Array; + mediaType: string; + filename?: string; +} + +/** + * AI SDK v3 / Anthropic-aligned content part. A section's content is + * always an array so text, files, and images can coexist without + * flattening. + */ +export type ContentPart = + | { type: "text"; text: string } + | { + type: "file"; + data: Uint8Array; + mediaType: string; + filename?: string; + } + | { type: "image"; data: Uint8Array; mediaType: string }; + +/** + * A resolved contribution from a single source. Every contributor + * produces one of these (or null on non-fatal failure). + */ +export interface ContextSection { + id: string; // stable, e.g. "issue:123" + kind: LaunchSourceKind; + label: string; + content: ContentPart[]; + meta?: { + taskSlug?: string; + url?: string; + }; +} + +/** + * Collaborators handed to every contributor. Kept small and explicit — + * contributors should not reach into globals. + */ +export interface ResolveCtx { + projectId: string; + signal: AbortSignal; + fetchIssue: (url: string) => Promise<GitHubIssueContent>; + fetchPullRequest: (url: string) => Promise<GitHubPullRequestContent>; + fetchInternalTask: (id: string) => Promise<InternalTaskContent>; +} + +export interface GitHubIssueContent { + number: number; + url: string; + title: string; + body: string; // already sanitized and truncated + slug: string; +} + +export interface GitHubPullRequestContent { + number: number; + url: string; + title: string; + body: string; + branch: string; +} + +export interface InternalTaskContent { + id: string; + slug: string; + title: string; + description: string | null; +} + +/** + * A contributor resolves one kind of LaunchSource into a ContextSection. + * Metadata (displayName/description/requiresQuery) is lifted from + * Continue.dev's context provider interface for future UI rendering. + */ +export interface ContextContributor<S extends LaunchSource = LaunchSource> { + kind: S["kind"]; + displayName: string; + description: string; + requiresQuery: boolean; + resolve(source: S, ctx: ResolveCtx): Promise<ContextSection | null>; +} + +export type ContributorRegistry = { + readonly [K in LaunchSourceKind]: ContextContributor< + Extract<LaunchSource, { kind: K }> + >; +}; + +/** + * Composer output. Agent-agnostic: feeds into any consumer + * (buildLaunchSpec, buildBranchNameContext, renderLaunchPreview, ...). + */ +export interface LaunchContext { + projectId: string; + sources: LaunchSource[]; + sections: ContextSection[]; + failures: Array<{ source: LaunchSource; error: string }>; + taskSlug?: string; + agent: { + id: AgentDefinitionId | "none"; + config?: ResolvedAgentConfig; + }; +} + +/** + * V2-native launch spec. Replaces the V1 `AgentLaunchRequest` shape + * (which flattened prompt to a single string). Maps cleanly to: + * - Anthropic Messages API: system blocks + user content parts. + * - AI SDK v3: ModelMessage with ContentPart[]. + * - Terminal adapters: flatten system+user to prompt text, write + * attachments to .superset/attachments/, reference by path. + */ +export interface AgentLaunchSpec { + agentId: AgentDefinitionId; + system: ContentPart[]; + user: ContentPart[]; + attachments: ContentPart[]; + taskSlug?: string; +} + +/** + * Inputs passed to buildLaunchContext. Contributors, resolvers, and + * timeout are passed as collaborators via ResolveCtx / options. + */ +export interface BuildLaunchContextInputs { + projectId: string; + sources: LaunchSource[]; + agent: LaunchContext["agent"]; +} diff --git a/apps/desktop/src/shared/utils/agent-settings.test.ts b/apps/desktop/src/shared/utils/agent-settings.test.ts index 47128fe828f..b0b2762de2a 100644 --- a/apps/desktop/src/shared/utils/agent-settings.test.ts +++ b/apps/desktop/src/shared/utils/agent-settings.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; import { getBuiltinAgentDefinition } from "@superset/shared/agent-catalog"; +import { + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, +} from "@superset/shared/agent-prompt-template"; import { applyCustomAgentDefinitionPatch, createOverrideEnvelopeWithPatch, @@ -268,3 +272,75 @@ describe("custom agent definition helpers", () => { ]); }); }); + +describe("contextPromptTemplate resolution", () => { + test("every built-in agent ships the default markdown templates", () => { + const configs = resolveAgentConfigs({}); + for (const config of configs) { + expect(config.contextPromptTemplateSystem).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ); + expect(config.contextPromptTemplateUser).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + ); + } + }); + + test("override replaces user template for terminal agents", () => { + const override = { + version: 1 as const, + presets: [ + { + id: "claude", + contextPromptTemplateUser: "custom user template {{userPrompt}}", + }, + ], + }; + const claude = resolveAgentConfigs({ overrideEnvelope: override }).find( + (p) => p.id === "claude", + ); + expect(claude?.contextPromptTemplateUser).toBe( + "custom user template {{userPrompt}}", + ); + expect(claude?.contextPromptTemplateSystem).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ); + expect(claude?.overriddenFields).toContain("contextPromptTemplateUser"); + }); + + test("override works for chat agents too", () => { + const override = { + version: 1 as const, + presets: [ + { + id: "superset-chat", + contextPromptTemplateSystem: "custom sys", + }, + ], + }; + const chat = resolveAgentConfigs({ overrideEnvelope: override }).find( + (p) => p.id === "superset-chat", + ); + expect(chat?.contextPromptTemplateSystem).toBe("custom sys"); + }); + + test("custom terminal agents without templates fall back to markdown defaults", () => { + const custom = resolveAgentConfigs({ + customDefinitions: [ + { + id: "custom:x", + kind: "terminal", + label: "X", + command: "x", + taskPromptTemplate: "t", + }, + ], + }).find((p) => p.id === "custom:x"); + expect(custom?.contextPromptTemplateSystem).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ); + expect(custom?.contextPromptTemplateUser).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + ); + }); +}); diff --git a/apps/desktop/src/shared/utils/agent-settings.ts b/apps/desktop/src/shared/utils/agent-settings.ts index c13e88ae28d..33d6bec1ccf 100644 --- a/apps/desktop/src/shared/utils/agent-settings.ts +++ b/apps/desktop/src/shared/utils/agent-settings.ts @@ -37,6 +37,8 @@ const TERMINAL_OVERRIDE_FIELDS = [ "promptCommand", "promptCommandSuffix", "taskPromptTemplate", + "contextPromptTemplateSystem", + "contextPromptTemplateUser", ] as const satisfies readonly AgentPresetField[]; const CHAT_OVERRIDE_FIELDS = [ @@ -44,6 +46,8 @@ const CHAT_OVERRIDE_FIELDS = [ "label", "description", "taskPromptTemplate", + "contextPromptTemplateSystem", + "contextPromptTemplateUser", "model", ] as const satisfies readonly AgentPresetField[]; @@ -77,6 +81,8 @@ export type AgentPresetPatch = Partial<{ promptCommand: string; promptCommandSuffix: string | null; taskPromptTemplate: string; + contextPromptTemplateSystem: string; + contextPromptTemplateUser: string; model: string | null; }>; @@ -89,6 +95,8 @@ export type CustomAgentDefinitionPatch = Partial<{ promptCommandSuffix: string | null; promptTransport: PromptTransport | null; taskPromptTemplate: string; + contextPromptTemplateSystem: string | null; + contextPromptTemplateUser: string | null; }>; function toUserTerminalAgentDefinition( @@ -105,6 +113,8 @@ function toUserTerminalAgentDefinition( promptCommandSuffix: customDefinition.promptCommandSuffix, promptTransport: customDefinition.promptTransport, taskPromptTemplate: customDefinition.taskPromptTemplate, + contextPromptTemplateSystem: customDefinition.contextPromptTemplateSystem, + contextPromptTemplateUser: customDefinition.contextPromptTemplateUser, enabled: customDefinition.enabled ?? true, }); } @@ -231,6 +241,14 @@ export function applyCustomAgentDefinitionPatch({ ) { nextDefinition.taskPromptTemplate = patch.taskPromptTemplate; } + if (Object.hasOwn(patch, "contextPromptTemplateSystem")) { + nextDefinition.contextPromptTemplateSystem = + patch.contextPromptTemplateSystem ?? undefined; + } + if (Object.hasOwn(patch, "contextPromptTemplateUser")) { + nextDefinition.contextPromptTemplateUser = + patch.contextPromptTemplateUser ?? undefined; + } return agentCustomDefinitionSchema.parse(nextDefinition); } @@ -313,6 +331,12 @@ function resolveAgentConfig( ), taskPromptTemplate: override?.taskPromptTemplate ?? definition.taskPromptTemplate, + contextPromptTemplateSystem: + override?.contextPromptTemplateSystem ?? + definition.contextPromptTemplateSystem, + contextPromptTemplateUser: + override?.contextPromptTemplateUser ?? + definition.contextPromptTemplateUser, overriddenFields: getOverriddenFields(override, definition), }; } @@ -325,6 +349,12 @@ function resolveAgentConfig( enabled: override?.enabled ?? definition.enabled, taskPromptTemplate: override?.taskPromptTemplate ?? definition.taskPromptTemplate, + contextPromptTemplateSystem: + override?.contextPromptTemplateSystem ?? + definition.contextPromptTemplateSystem, + contextPromptTemplateUser: + override?.contextPromptTemplateUser ?? + definition.contextPromptTemplateUser, model: resolveModel(definition.model, override), overriddenFields: getOverriddenFields(override, definition), }; @@ -508,6 +538,21 @@ export function createOverrideEnvelopeWithPatch({ patch.taskPromptTemplate !== definition.taskPromptTemplate, ); } + if (hasField("contextPromptTemplateSystem")) { + setOrDelete( + "contextPromptTemplateSystem", + patch.contextPromptTemplateSystem, + patch.contextPromptTemplateSystem !== + definition.contextPromptTemplateSystem, + ); + } + if (hasField("contextPromptTemplateUser")) { + setOrDelete( + "contextPromptTemplateUser", + patch.contextPromptTemplateUser, + patch.contextPromptTemplateUser !== definition.contextPromptTemplateUser, + ); + } if (definition.kind === "terminal") { if (hasField("command")) { diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index f42fe4c9bf8..8b5b107685a 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -176,6 +176,8 @@ const agentPresetOverrideSchema = z.object({ promptCommand: z.string().optional(), promptCommandSuffix: z.string().nullable().optional(), taskPromptTemplate: z.string().optional(), + contextPromptTemplateSystem: z.string().optional(), + contextPromptTemplateUser: z.string().optional(), model: z.string().optional(), }); @@ -194,6 +196,8 @@ const agentCustomDefinitionSchema = z.object({ promptCommandSuffix: z.string().optional(), promptTransport: z.enum(["argv", "stdin"]).optional(), taskPromptTemplate: z.string(), + contextPromptTemplateSystem: z.string().optional(), + contextPromptTemplateUser: z.string().optional(), enabled: z.boolean().optional(), }); diff --git a/bun.lock b/bun.lock index 790ce1b8879..e4ec3c90284 100644 --- a/bun.lock +++ b/bun.lock @@ -240,6 +240,7 @@ "date-fns": "^4.1.0", "default-shell": "^2.2.0", "detect-libc": "2.0.4", + "dexie": "^4.4.2", "diff": "^7.0.0", "dnd-core": "^16.0.1", "dockerfile-ast": "0.7.1", @@ -263,7 +264,6 @@ "highlight.js": "^11.11.1", "html-to-image": "^1.11.13", "http-proxy": "^1.18.1", - "idb": "^8.0.3", "idb-keyval": "^6.2.2", "jose": "^6.1.3", "js-yaml": "^4.1.1", @@ -3675,6 +3675,8 @@ "devtools-protocol": ["devtools-protocol@0.0.1581282", "", {}, "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ=="], + "dexie": ["dexie@4.4.2", "", {}, "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw=="], + "diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], @@ -4207,8 +4209,6 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], - "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts new file mode 100644 index 00000000000..451f7989b17 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts @@ -0,0 +1,28 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getStrictShellEnvironment } from "../../../../terminal/clean-shell-env"; + +const execFileAsync = promisify(execFile); + +/** + * Shell out to the user's `gh` CLI. Uses the user's existing gh + * authentication (`gh auth login`), which is simpler than octokit + + * credential-manager plumbing and matches V1's behavior for + * getIssueContent. + * + * Returns parsed JSON output. Throws on non-zero exit or JSON parse + * failure. + */ +export async function execGh(args: string[]): Promise<unknown> { + const env = await getStrictShellEnvironment().catch( + () => process.env as Record<string, string>, + ); + const { stdout } = await execFileAsync("gh", args, { + encoding: "utf8", + timeout: 10_000, + env, + }); + const trimmed = stdout.trim(); + if (!trimmed) return {}; + return JSON.parse(trimmed); +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index 8991f6ddab7..49514552376 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -16,6 +16,7 @@ import { import { createTerminalSessionInternal } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; +import { execGh } from "./utils/exec-gh"; import { resolveStartPoint } from "./utils/resolve-start-point"; import { deduplicateBranchName } from "./utils/sanitize-branch"; @@ -1347,6 +1348,11 @@ export const workspaceCreationRouter = router({ } }), + // Shell out to the user's `gh` CLI rather than host-service's + // octokit — `gh auth login` works out of the box while the + // credential-manager path requires setup most users don't have. + // Matches V1's projects.getIssueContent behavior. + getGitHubIssueContent: protectedProcedure .input( z.object({ @@ -1356,22 +1362,26 @@ export const workspaceCreationRouter = router({ ) .query(async ({ ctx, input }) => { const repo = await resolveGithubRepo(ctx, input.projectId); - const octokit = await ctx.github(); try { - const { data } = await octokit.issues.get({ - owner: repo.owner, - repo: repo.name, - issue_number: input.issueNumber, - }); + const raw = await execGh([ + "issue", + "view", + String(input.issueNumber), + "--repo", + `${repo.owner}/${repo.name}`, + "--json", + "number,title,body,url,state,author,createdAt,updatedAt", + ]); + const data = IssueSchema.parse(raw); return { number: data.number, title: data.title, body: data.body ?? "", - url: data.html_url, - state: data.state, - author: data.user?.login ?? null, - createdAt: data.created_at, - updatedAt: data.updated_at, + url: data.url, + state: data.state.toLowerCase(), + author: data.author?.login ?? null, + createdAt: data.createdAt, + updatedAt: data.updatedAt, }; } catch (err) { throw new TRPCError({ @@ -1380,4 +1390,70 @@ export const workspaceCreationRouter = router({ }); } }), + + getGitHubPullRequestContent: protectedProcedure + .input( + z.object({ + projectId: z.string(), + prNumber: z.number().int().positive(), + }), + ) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + try { + const raw = await execGh([ + "pr", + "view", + String(input.prNumber), + "--repo", + `${repo.owner}/${repo.name}`, + "--json", + "number,title,body,url,state,author,headRefName,baseRefName,isDraft,createdAt,updatedAt", + ]); + const data = PrSchema.parse(raw); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.url, + state: data.state.toLowerCase(), + branch: data.headRefName, + baseBranch: data.baseRefName, + author: data.author?.login ?? null, + isDraft: data.isDraft, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + }; + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to fetch PR #${input.prNumber}: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), +}); + +const IssueSchema = z.object({ + number: z.number(), + title: z.string(), + body: z.string().nullable().optional(), + url: z.string(), + state: z.string(), + author: z.object({ login: z.string() }).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}); + +const PrSchema = z.object({ + number: z.number(), + title: z.string(), + body: z.string().nullable().optional(), + url: z.string(), + state: z.string(), + headRefName: z.string(), + baseRefName: z.string(), + isDraft: z.boolean(), + author: z.object({ login: z.string() }).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), }); diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index c2d4ab37162..623a2d9d194 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -126,6 +126,8 @@ export const AGENT_PRESET_FIELDS = [ "promptCommand", "promptCommandSuffix", "taskPromptTemplate", + "contextPromptTemplateSystem", + "contextPromptTemplateUser", "model", ] as const; @@ -144,6 +146,8 @@ export const agentPresetOverrideSchema = z.object({ promptCommand: z.string().optional(), promptCommandSuffix: z.string().nullable().optional(), taskPromptTemplate: z.string().optional(), + contextPromptTemplateSystem: z.string().optional(), + contextPromptTemplateUser: z.string().optional(), model: z.string().optional(), }); @@ -168,6 +172,8 @@ export const agentCustomDefinitionSchema = z.object({ promptCommandSuffix: z.string().optional(), promptTransport: z.enum(PROMPT_TRANSPORTS).optional(), taskPromptTemplate: z.string(), + contextPromptTemplateSystem: z.string().optional(), + contextPromptTemplateUser: z.string().optional(), enabled: z.boolean().optional(), }); diff --git a/packages/shared/src/agent-catalog.ts b/packages/shared/src/agent-catalog.ts index 685f7614337..dba367e6330 100644 --- a/packages/shared/src/agent-catalog.ts +++ b/packages/shared/src/agent-catalog.ts @@ -5,7 +5,11 @@ import type { ChatAgentDefinition, TerminalAgentDefinition, } from "./agent-definition"; -import { DEFAULT_CHAT_TASK_PROMPT_TEMPLATE } from "./agent-prompt-template"; +import { + DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, +} from "./agent-prompt-template"; import { BUILTIN_TERMINAL_AGENT_TYPES, BUILTIN_TERMINAL_AGENTS, @@ -43,6 +47,8 @@ const BUILTIN_CHAT_AGENT: ChatAgentDefinition = { "Superset's built-in workspace chat for project-aware help and task launches.", enabled: true, taskPromptTemplate: DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, + contextPromptTemplateSystem: DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + contextPromptTemplateUser: DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, }; export const BUILTIN_AGENT_DEFINITIONS: AgentDefinition[] = [ diff --git a/packages/shared/src/agent-definition.ts b/packages/shared/src/agent-definition.ts index 9743194f544..59002ad41e7 100644 --- a/packages/shared/src/agent-definition.ts +++ b/packages/shared/src/agent-definition.ts @@ -1,4 +1,8 @@ import type { PromptTransport } from "./agent-prompt-launch"; +import { + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, +} from "./agent-prompt-template"; export type AgentDefinitionSource = "builtin" | "user"; export type AgentKind = "terminal" | "chat"; @@ -11,6 +15,18 @@ interface BaseAgentDefinition { description?: string; enabled: boolean; taskPromptTemplate: string; + /** + * Mustache template with AGENT_CONTEXT_PROMPT_VARIABLES. Rendered into + * the system portion of the V2 AgentLaunchSpec (cacheable, stable + * content like AGENTS.md). + */ + contextPromptTemplateSystem: string; + /** + * Mustache template with AGENT_CONTEXT_PROMPT_VARIABLES. Rendered into + * the user portion of the V2 AgentLaunchSpec (per-launch content: + * user prompt, linked issues/PRs/tasks, attachments). + */ + contextPromptTemplateUser: string; } export interface TerminalAgentDefinition extends BaseAgentDefinition { @@ -22,9 +38,17 @@ export interface TerminalAgentDefinition extends BaseAgentDefinition { } export interface TerminalAgentDefinitionInput - extends Omit<TerminalAgentDefinition, "promptCommand" | "promptTransport"> { + extends Omit< + TerminalAgentDefinition, + | "promptCommand" + | "promptTransport" + | "contextPromptTemplateSystem" + | "contextPromptTemplateUser" + > { promptCommand?: string; promptTransport?: PromptTransport; + contextPromptTemplateSystem?: string; + contextPromptTemplateUser?: string; } export interface ChatAgentDefinition extends BaseAgentDefinition { @@ -41,6 +65,11 @@ export function createTerminalAgentDefinition( ...input, promptCommand: input.promptCommand ?? input.command, promptTransport: input.promptTransport ?? "argv", + contextPromptTemplateSystem: + input.contextPromptTemplateSystem ?? + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + contextPromptTemplateUser: + input.contextPromptTemplateUser ?? DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, }; } diff --git a/packages/shared/src/agent-prompt-template.test.ts b/packages/shared/src/agent-prompt-template.test.ts index 28e1b26f0d5..fc612c1ca40 100644 --- a/packages/shared/src/agent-prompt-template.test.ts +++ b/packages/shared/src/agent-prompt-template.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; import { + AGENT_CONTEXT_PROMPT_VARIABLES, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + getSupportedContextPromptVariables, + renderPromptTemplate, renderTaskPromptTemplate, + validateContextPromptTemplate, validateTaskPromptTemplate, } from "./agent-prompt-template"; @@ -14,7 +20,7 @@ const TASK = { labels: ["desktop"], }; -describe("renderTaskPromptTemplate", () => { +describe("renderTaskPromptTemplate (shim)", () => { test("renders placeholders with surrounding whitespace", () => { const rendered = renderTaskPromptTemplate( "Task {{ title }} / {{ slug }}", @@ -33,3 +39,100 @@ describe("validateTaskPromptTemplate", () => { }); }); }); + +describe("renderPromptTemplate (generic)", () => { + test("substitutes from a Record<string, string>", () => { + const rendered = renderPromptTemplate("Hello {{name}}, age {{age}}", { + name: "kiet", + age: "98", + }); + expect(rendered).toBe("Hello kiet, age 98"); + }); + + test("tolerates whitespace inside braces", () => { + expect( + renderPromptTemplate("{{ foo }} {{ bar }}", { foo: "a", bar: "b" }), + ).toBe("a b"); + }); + + test("leaves unknown placeholders intact", () => { + expect(renderPromptTemplate("Hi {{unknown}}", { name: "x" })).toBe( + "Hi {{unknown}}", + ); + }); + + test("empty string values substitute (not treated as missing)", () => { + expect(renderPromptTemplate("[{{x}}]", { x: "" })).toBe("[]"); + }); + + test("collapses 3+ consecutive newlines to 2", () => { + expect(renderPromptTemplate("a\n\n\n\nb", {})).toBe("a\n\nb"); + }); + + test("trims leading and trailing whitespace", () => { + expect(renderPromptTemplate(" hi ", {})).toBe("hi"); + }); +}); + +describe("context prompt variables", () => { + test("AGENT_CONTEXT_PROMPT_VARIABLES covers launch sources", () => { + expect(AGENT_CONTEXT_PROMPT_VARIABLES).toEqual([ + "userPrompt", + "tasks", + "issues", + "prs", + "attachments", + ]); + }); + + test("getSupportedContextPromptVariables returns a copy", () => { + const vars = getSupportedContextPromptVariables(); + expect(vars).toEqual([...AGENT_CONTEXT_PROMPT_VARIABLES]); + vars.push("mutated" as never); + expect(AGENT_CONTEXT_PROMPT_VARIABLES).toHaveLength(5); + }); +}); + +describe("validateContextPromptTemplate", () => { + test("accepts templates with only known variables", () => { + expect(validateContextPromptTemplate("{{userPrompt}} {{issues}}")).toEqual({ + valid: true, + unknownVariables: [], + }); + }); + + test("flags unknown variables", () => { + expect(validateContextPromptTemplate("{{slackThread}} {{issues}}")).toEqual( + { + valid: false, + unknownVariables: ["slackThread"], + }, + ); + }); +}); + +describe("default context templates", () => { + test("markdown defaults only reference known variables", () => { + expect( + validateContextPromptTemplate(DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER).valid, + ).toBe(true); + expect( + validateContextPromptTemplate(DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM) + .valid, + ).toBe(true); + }); + + test("rendering the user template collapses empty sections cleanly", () => { + const rendered = renderPromptTemplate( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + { + userPrompt: "refactor auth", + tasks: "", + issues: "", + prs: "", + attachments: "", + }, + ); + expect(rendered).toBe("refactor auth"); + }); +}); diff --git a/packages/shared/src/agent-prompt-template.ts b/packages/shared/src/agent-prompt-template.ts index 2ce05f6a279..807d487b14b 100644 --- a/packages/shared/src/agent-prompt-template.ts +++ b/packages/shared/src/agent-prompt-template.ts @@ -1,5 +1,51 @@ import type { TaskInput } from "./agent-command"; +// --------------------------------------------------------------------------- +// Generic template rendering +// --------------------------------------------------------------------------- + +/** + * Render a Mustache-lite template with `{{var}}` placeholders. + * + * - Unknown variables are left intact (task templates rely on this so + * typos surface at validate-time instead of silently dropping). + * - Empty-string values substitute in (so `{{tasks}}` with no tasks + * collapses cleanly instead of leaving the placeholder visible). + * - Runs of 3+ newlines collapse to 2, and the result is trimmed, so + * templates with empty variables don't produce huge gaps. + */ +export function renderPromptTemplate( + template: string, + variables: Record<string, string>, +): string { + return substituteOwnProperties(template, variables) + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** + * Placeholder substitution that only reads OWN properties of the + * variables object. Prevents `{{toString}}` and other inherited + * property names from resolving through the prototype chain. + */ +function substituteOwnProperties( + template: string, + variables: Record<string, string>, +): string { + return template.replace( + /\{\{\s*([^}]+?)\s*\}\}/g, + (match, rawKey: string) => { + const key = rawKey.trim(); + if (!Object.hasOwn(variables, key)) return match; + return variables[key] ?? match; + }, + ); +} + +// --------------------------------------------------------------------------- +// Task prompt variables (unchanged from v1 — used by the task-run flow) +// --------------------------------------------------------------------------- + export const AGENT_TASK_PROMPT_VARIABLES = [ "id", "slug", @@ -45,18 +91,19 @@ function getTaskPromptVariables(task: TaskInput): TaskPromptVariables { }; } +/** + * Shim preserved so the existing task-run flow keeps working unchanged. + * New callers should prefer `renderPromptTemplate` directly. + * + * Matches V1 semantics exactly: own-property substitution + trim. + * Does NOT apply the generic's 3+-newline collapse pass — task + * templates may rely on intentional blank lines. + */ export function renderTaskPromptTemplate( template: string, task: TaskInput, ): string { - const variables = getTaskPromptVariables(task); - - return template - .replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, rawKey: string) => { - const key = rawKey.trim() as AgentTaskPromptVariable; - return variables[key] ?? match; - }) - .trim(); + return substituteOwnProperties(template, getTaskPromptVariables(task)).trim(); } export function getSupportedTaskPromptVariables(): AgentTaskPromptVariable[] { @@ -67,14 +114,70 @@ export function validateTaskPromptTemplate(template: string): { valid: boolean; unknownVariables: string[]; } { + return validateTemplate(template, AGENT_TASK_PROMPT_VARIABLES); +} + +// --------------------------------------------------------------------------- +// Context prompt variables (new — used by V2 launch composition) +// --------------------------------------------------------------------------- + +export const AGENT_CONTEXT_PROMPT_VARIABLES = [ + "userPrompt", + "tasks", + "issues", + "prs", + "attachments", +] as const; + +export type AgentContextPromptVariable = + (typeof AGENT_CONTEXT_PROMPT_VARIABLES)[number]; + +export function getSupportedContextPromptVariables(): AgentContextPromptVariable[] { + return [...AGENT_CONTEXT_PROMPT_VARIABLES]; +} + +export function validateContextPromptTemplate(template: string): { + valid: boolean; + unknownVariables: string[]; +} { + return validateTemplate(template, AGENT_CONTEXT_PROMPT_VARIABLES); +} + +/** + * Default context templates. Plain markdown — works for every agent + * (Claude, Codex, Cursor, custom). Users can override per-agent in + * settings if they want XML or other wrapping. + * + * System is empty by default — agent harnesses (Claude CLI, Codex, etc.) + * discover their own instructions files from the worktree. + */ +export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM = ""; + +export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER = `{{userPrompt}} + +{{tasks}} + +{{issues}} + +{{prs}} + +{{attachments}}`; + +// --------------------------------------------------------------------------- +// Shared validator +// --------------------------------------------------------------------------- + +function validateTemplate( + template: string, + known: readonly string[], +): { valid: boolean; unknownVariables: string[] } { const unknownVariables = Array.from( new Set( Array.from(template.matchAll(/\{\{([^}]+)\}\}/g)) .map((match) => match[1]?.trim()) .filter( (value): value is string => - !!value && - !(AGENT_TASK_PROMPT_VARIABLES as readonly string[]).includes(value), + !!value && !(known as readonly string[]).includes(value), ), ), ); diff --git a/plans/done/v2-workspace-context-composition.md b/plans/done/v2-workspace-context-composition.md new file mode 100644 index 00000000000..007937f0ae2 --- /dev/null +++ b/plans/done/v2-workspace-context-composition.md @@ -0,0 +1,243 @@ +# V2 Workspace Launch Context — Composition + +Closes Gaps 3, 4, 5 (unblocks 6) in `apps/desktop/V2_WORKSPACE_MODAL_GAPS.md`. +V2-only — V1 stays as-is. We rewrite where V1's shape is wrong; we duplicate +where V1 is fine. + +## Problem + +V2 launch must assemble context from many heterogeneous sources (user +prompt, linked issues, linked PR, linked tasks, attachments, agent +instructions, selected agent). Today `useSubmitWorkspace` sends a flat +string prompt + URLs. Doesn't scale to add Notion / Linear / repo docs +/ per-agent formatting / prompt caching. + +## Vendor lessons + +- **AI SDK v3** — `ModelMessage.content: ContentPart[]` (text | file | + image). No string flatten. We adopt for V2 spec. +- **Anthropic API** — `system: Array<{type:'text', text, cache_control?}>`. + Stable context lives in cacheable system blocks, not jammed into the + user message every turn. We adopt the system/user split + ephemeral + cache hint. +- **Continue.dev** — contributors carry `displayName`, `description`, + `requiresQuery`. We adopt for free UI/validation. +- **Cursor** — agent declares supported context kinds. Defer to phase 2. +- **Mastra/Continue streaming** — partial context streaming. Defer. +- **Cline/V1 monolithic string** — explicitly reject. + +## Architecture + +``` +Inputs → resolve sources → LaunchContext → buildLaunchSpec → executeAgentLaunch +``` + +### Types + +```ts +type LaunchSource = + | { kind: "user-prompt"; text: string } + | { kind: "github-issue"; url: string } + | { kind: "github-pr"; url: string } + | { kind: "internal-task"; id: string } + | { kind: "attachment"; file: ConvertedFile } + | { kind: "agent-instructions"; path: string }; + +type ContentPart = + | { type: "text"; text: string } + | { type: "file"; data: Uint8Array; mediaType: string; filename?: string } + | { type: "image"; data: Uint8Array; mediaType: string }; + +interface ContextContributor<S extends LaunchSource> { + kind: S["kind"]; + displayName: string; // "GitHub Issue" + description: string; + requiresQuery: boolean; + resolve(source: S, ctx: ResolveCtx): Promise<ContextSection | null>; +} + +interface ContextSection { + id: string; // "issue:123" + kind: LaunchSource["kind"]; + scope: "system" | "user"; + label: string; + content: ContentPart[]; + cacheControl?: "ephemeral"; + meta?: { taskSlug?: string; url?: string }; +} + +interface LaunchContext { + projectId: string; + sources: LaunchSource[]; + sections: ContextSection[]; + failures: Array<{ source: LaunchSource; error: string }>; + taskSlug?: string; + agent: { id: AgentDefinitionId | "none"; config?: ResolvedAgentConfig }; +} + +// V2-native — replaces V1's flat AgentLaunchRequest for the V2 path. +interface AgentLaunchSpec { + agentId: AgentDefinitionId; + system: ContentPart[]; // stable, cacheable + user: ContentPart[]; // per-launch + attachments: ContentPart[]; // file/image parts kept separate + taskSlug?: string; +} +``` + +Default scopes: `agent-instructions` → system (cached). Everything else +→ user. Contributors may override per-source. + +### Multi-source rules + +- Array in, array out. Multi-of-kind + mixed-kind is the default. +- Input order preserved within a kind. +- Kind group order: `user-prompt → internal-task → github-issue → github-pr → attachment → agent-instructions`. +- Dedup by `source.id` pre-dispatch. +- `taskSlug`: first `internal-task` → first `github-issue` → undefined. +- File parts merge flat with collision-safe naming. +- Per-source failure → `failures[]` entry + null section + toast; launch proceeds. +- Multi-agent fan-out = run `buildLaunchSpec` N times. + +### `ResolvedAgentConfig` extension + +Add `contextPromptTemplate: { system: string; user: string }` (Mustache; +vars: `{{userPrompt}}`, `{{tasks}}`, `{{issues}}`, `{{prs}}`, +`{{attachments}}`, `{{agentInstructions}}`). Per-builtin defaults: Claude +ships XML-tagged sections; codex/cursor ship markdown headers. User +overrides via existing settings UI. + +Rename `renderTaskPromptTemplate` → `renderPromptTemplate`. Add +`getSupportedContextPromptVariables()` next to the task variant. + +### Pipeline + +1. `buildLaunchContext(inputs)` — parallel resolve, dedup, order, + `failures[]`, taskSlug derivation. +2. `buildLaunchSpec(ctx, agentConfig) → AgentLaunchSpec` — group by + `scope`, render template into `system`/`user` content, preserve file/ + image parts in `attachments`, attach `cache_control` to system. +3. `executeAgentLaunch(spec, agentConfig)`: + - **Chat**: structured passthrough (Anthropic system blocks + user + `ContentPart[]`; AI-SDK shape). + - **Terminal**: flatten `system + user` to text via existing + `buildPromptCommandFromAgentConfig` + transport; write attachments + to `.superset/attachments/` with refs in user content. Flatten is + per-transport; spec stays structured. + +### Attachment transport — bytes, not base64 + +V1 stores attachments as base64 data URLs end-to-end (IDB → Zustand → +tRPC-electron → `filesystem.writeFile({kind:"base64"})`). 33% size +overhead on every hop; 10MB PDF becomes a 13MB string in memory +repeatedly. + +V2 ships `Uint8Array` natively: + +- **Intake**: store `Blob` in IndexedDB (IDB supports Blobs first-class). +- **IPC**: pass `Uint8Array` over tRPC-electron with a JSON-safe + transformer (SuperJSON handles typed arrays). +- **Disk write**: add `filesystem.writeFile({kind:"bytes", data: Uint8Array})`; + terminal adapter's `writeAttachmentFiles` skips the base64 round-trip. +- **Chat provider boundary** (Anthropic/AI SDK HTTP): encode base64 + **once** right before the API call. Nowhere else in V2. +- **CLI / terminal agents**: never base64. Files land on disk via + `writeAttachmentFiles`; prompt text references `.superset/attachments/ + <filename>`. CLIs read the filesystem — that's the right interface + for them. +- **Phase 6 (chat only)**: Anthropic Files API — upload once, reference + by file ID across chat launches. Smaller payloads, server-side cache. + Does not apply to CLI agents. + +`writeAttachmentFiles` collision-safe naming (sanitize → `attachment_N` +fallback → dedup `foo_1.png`) stays. Size/count limits stay. + +### Extensibility + +- New source = union variant + contributor file (with metadata) + registry entry. +- New consumer = one file reading `LaunchContext` or `AgentLaunchSpec`. +- New agent = entry in `ResolvedAgentConfig` (settings UI). +- New transport (e.g. native chat with file blocks) = one branch in `executeAgentLaunch`. + +TypeScript errors at every integration point if a step is skipped. + +## File layout + +``` +apps/desktop/src/shared/context/ + types.ts // LaunchSource, ContextSection, LaunchContext, AgentLaunchSpec, ContentPart + composer.ts // buildLaunchContext + buildLaunchSpec.ts + executeAgentLaunch.ts + contributors/{userPrompt,githubIssue,githubPr,internalTask,attachment,agentInstructions}.ts + consumers/{branchName,preview,createFromPr}.ts +apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/ // V2-owned, separate from V1 store +``` + +`shared/context/` has no React deps. + +## V2 integration + +- `useSubmitWorkspace.ts`: build `LaunchSource[]` from draft → `buildLaunchContext` → `buildLaunchSpec(ctx, agentConfig)`. +- After host-service `createWorkspace` resolves: `useEnqueueAgentLaunch(workspaceId, spec)`. V2-owned store; structured `AgentLaunchSpec`. Does **not** reuse V1's `useWorkspaceInitStore` — spec shape differs. +- Remote hosts (`hostTarget.kind === "remote"`) throw for now (no regression). +- Host-service `workspaceCreation.create` unchanged in phase 1. + +V1 keeps its `AgentLaunchRequest` + `addPendingTerminalSetup` flow +untouched. Some duplication; intentional. + +## Testing (TDD) + +Pure functions throughout. Red → green each step. + +- **Fixtures**: `__fixtures__/` with raw GH/task JSON + canonical `LaunchContext` + per-agent spec snapshots. +- **Contributors**: unit tests with stubbed `resolveCtx` (sanitize, truncate, null-on-404, scope assignment, slug derivation). +- **Composer**: dedup, order, taskSlug precedence, partial failure (`failures[]` populated), file merge, 10s per-contributor timeout. +- **`buildLaunchSpec`**: snapshot per agent (Claude XML, codex markdown, cursor markdown, raw); empty kinds skipped; file parts preserved (not flattened to text). +- **`executeAgentLaunch`**: chat passthrough preserves structure; terminal flatten produces correct command; attachments written to filesystem refs. + +## Execution order (TDD) + +1. `types.ts` (incl. `AgentLaunchSpec`, `ContentPart`) + fixtures. +2. Composer test → impl. Returns `{sections, failures, taskSlug}`. +3. Contributors with metadata, in order: `userPrompt`, `attachment`, `agentInstructions`, `githubIssue`, `githubPr`, `internalTask`. +4. `agent-prompt-template`: rename `renderTaskPromptTemplate` → `renderPromptTemplate`; add `getSupportedContextPromptVariables()`; add `DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM` + `_USER`; add Claude-XML default. +5. Extend `ResolvedAgentConfig` (terminal + chat) with `contextPromptTemplate: {system, user}`; thread through `resolveAgentConfig`, override fields, settings DB schema, per-builtin defaults. +6. `buildLaunchSpec(ctx, agentConfig)` — group by scope, render templates, preserve file/image parts. Snapshot per agent. +7. `executeAgentLaunch(spec, agentConfig)` — chat structured passthrough (base64 encode only at provider boundary); terminal flatten + `writeAttachmentFiles` via new `filesystem.writeFile({kind:"bytes"})` path. Add SuperJSON (or equivalent) transformer to tRPC-electron for `Uint8Array` IPC. +8. `useEnqueueAgentLaunch` hook (V2 store). +9. Wire into `useSubmitWorkspace`. Gaps 4, 5 closed. +10. `buildBranchNameContext` + wire AI branch name. Gap 3 closed. +11. `buildCreateFromPrInput` + wire PR-linked path. Gap 6 closed. + +## Phases + +1. Steps 1–9 above. Closes Gaps 4, 5 (local hosts). +2. Step 10. Closes Gap 3. +3. Step 11. Closes Gap 6. +4. Task popover migration (`RunInWorkspacePopover`, `OpenInWorkspace`) → `{kind: "internal-task", id}` sources. +5. Remote-host launch over tRPC (host-service-side `executeAgentLaunch`). +6. Anthropic Files API for **chat** attachments — upload once, reference by ID. CLI agents unaffected (stay on filesystem + path-ref pattern). +7. Phase-2 vendor adoptions if needed: streaming partial context, agent-declared supported kinds, token budgeting. + +## Risks + +- **Over-abstraction** — phase 1 ships V2 end-to-end; abstraction flexes immediately at step 9. Re-evaluate if friction. +- **Prompt shape drift** — snapshot tests per agent. +- **Slow fetch stalls submit** — 10s per-contributor timeout; partial failures non-fatal. +- **V2/V1 duplication of pending-setup store** — intentional; consolidating risks regressing V1. +- **Remote hosts** — explicit throw until phase 5. +- **IPC transformer regression risk** — adding SuperJSON affects all existing tRPC-electron calls. Gate behind tests, roll out carefully. + +## Open questions + +1. Live-reactive preview vs. pure on-submit? Pure first. +2. Agent picker in V2 modal? Add a default-agent display pill at minimum. +3. Token budget / pruning — no vendor implements at composition layer (Anthropic enforces 200k API-side). Defer. +4. Streaming partial context (Mastra/Continue) — defer to phase 6. +5. Cross-process composer (CLI / host-service-side) — promote to shared package when needed. + +## Non-goals + +LLM framework. Server-side prompt assembly (phase 1). Streaming +composition. V1 changes. Token budgeting in composition layer.