From 75485542945d6c4ef587aba720b2027f603fa7a6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 14 Apr 2026 19:08:02 -0700 Subject: [PATCH 01/42] Create doc --- plans/v2-workspace-context-composition.md | 239 ++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 plans/v2-workspace-context-composition.md diff --git a/plans/v2-workspace-context-composition.md b/plans/v2-workspace-context-composition.md new file mode 100644 index 00000000000..bf8009d399b --- /dev/null +++ b/plans/v2-workspace-context-composition.md @@ -0,0 +1,239 @@ +# 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 { + kind: S["kind"]; + displayName: string; // "GitHub Issue" + description: string; + requiresQuery: boolean; + resolve(source: S, ctx: ResolveCtx): Promise; +} + +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. +- **Provider API boundary** (chat → Anthropic/AI SDK): encode base64 + **once** right before the API call. Nowhere else in V2 code. +- **Phase 6+**: migrate chat to Anthropic Files API — upload once, + reference by file ID across launches. Aligns with cacheable system + blocks. + +`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 across launches. +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. From f6a4c27800963c45fb014de46d066a9100aff433 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 14 Apr 2026 19:18:13 -0700 Subject: [PATCH 02/42] docs(desktop): finalize v2 launch context plan Replace initial draft with V2-greenfield architecture: structured AgentLaunchSpec (system/user/attachments ContentPart[]), per-agent contextPromptTemplate on ResolvedAgentConfig, Uint8Array over IPC, vendor-aligned (AI SDK v3, Anthropic cache_control, Continue.dev contributor metadata). CLI agents keep disk + path-ref pattern; chat agents get structured passthrough with Files API as phase 6. --- plans/v2-workspace-context-composition.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/plans/v2-workspace-context-composition.md b/plans/v2-workspace-context-composition.md index bf8009d399b..007937f0ae2 100644 --- a/plans/v2-workspace-context-composition.md +++ b/plans/v2-workspace-context-composition.md @@ -139,11 +139,15 @@ V2 ships `Uint8Array` natively: transformer (SuperJSON handles typed arrays). - **Disk write**: add `filesystem.writeFile({kind:"bytes", data: Uint8Array})`; terminal adapter's `writeAttachmentFiles` skips the base64 round-trip. -- **Provider API boundary** (chat → Anthropic/AI SDK): encode base64 - **once** right before the API call. Nowhere else in V2 code. -- **Phase 6+**: migrate chat to Anthropic Files API — upload once, - reference by file ID across launches. Aligns with cacheable system - blocks. +- **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/ + `. 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. @@ -213,7 +217,7 @@ Pure functions throughout. Red → green each step. 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 across launches. +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 From 659abc1fe674e6aa2a8e447898f080b0f5344f91 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 14 Apr 2026 19:22:27 -0700 Subject: [PATCH 03/42] feat(desktop/context): add v2 launch context types and fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the v2 launch-context composition plan. Defines the core discriminated types (LaunchSource, ContextSection, LaunchContext, AgentLaunchSpec, ContentPart with Uint8Array data) and the canonical multi-source + prompt-only fixtures that the composer and buildLaunchSpec tests will share. No runtime code yet — types and fixtures only. --- .../__fixtures__/attachment.logs-txt.ts | 21 +++ .../githubIssue.auth-middleware.ts | 17 ++ .../__fixtures__/githubPr.auth-rewrite.ts | 9 + .../src/shared/context/__fixtures__/index.ts | 6 + .../internalTask.refactor-auth.ts | 9 + .../launchContext.multi-source.ts | 128 ++++++++++++++ .../__fixtures__/launchContext.prompt-only.ts | 22 +++ apps/desktop/src/shared/context/types.ts | 163 ++++++++++++++++++ 8 files changed, 375 insertions(+) create mode 100644 apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts create mode 100644 apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts create mode 100644 apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts create mode 100644 apps/desktop/src/shared/context/__fixtures__/index.ts create mode 100644 apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts create mode 100644 apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts create mode 100644 apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts create mode 100644 apps/desktop/src/shared/context/types.ts 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..3efbd1502d8 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts @@ -0,0 +1,21 @@ +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..0e07ae59000 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -0,0 +1,128 @@ +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", 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 }, + { kind: "agent-instructions", path: "/worktree/AGENTS.md" }, +]; + +export const launchContextMultiSource: LaunchContext = { + projectId: "project-1", + sources, + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + { + id: `task:${internalTaskRefactorAuth.id}`, + kind: "internal-task", + scope: "user", + label: "Task TASK-42 — Refactor auth middleware", + content: [ + { + type: "text", + text: + "Split session-token storage from request handling so we can encrypt at rest.", + }, + ], + meta: { taskSlug: internalTaskRefactorAuth.slug }, + }, + { + id: `issue:${githubIssueAuthMiddleware.number}`, + kind: "github-issue", + scope: "user", + label: "Issue #123 — Auth middleware stores tokens in plaintext", + content: [ + { + type: "text", + text: + "Legal flagged this. Sessions written to disk without encryption.", + }, + ], + meta: { + url: githubIssueAuthMiddleware.url, + taskSlug: githubIssueAuthMiddleware.slug, + }, + }, + { + id: `issue:${githubIssueTokenRotation.number}`, + kind: "github-issue", + scope: "user", + label: "Issue #124 — Rotate session tokens on password change", + content: [{ type: "text", text: "Follow-up for #123." }], + meta: { + url: githubIssueTokenRotation.url, + taskSlug: githubIssueTokenRotation.slug, + }, + }, + { + id: `pr:${githubPrAuthRewrite.number}`, + kind: "github-pr", + scope: "user", + label: "PR #200 — Rewrite auth middleware", + content: [ + { + type: "text", + text: "Replaces plaintext token storage with encrypted KV.", + }, + ], + meta: { url: githubPrAuthRewrite.url }, + }, + { + id: "attachment:logs.txt", + kind: "attachment", + scope: "user", + label: "logs.txt", + content: [ + { + type: "file", + data: attachmentLogsTxt.data, + mediaType: attachmentLogsTxt.mediaType, + filename: attachmentLogsTxt.filename, + }, + ], + }, + { + id: "attachment:screenshot.png", + kind: "attachment", + scope: "user", + label: "screenshot.png", + content: [ + { + type: "image", + data: attachmentScreenshotPng.data, + mediaType: attachmentScreenshotPng.mediaType, + }, + ], + }, + { + id: "agent-instructions:/worktree/AGENTS.md", + kind: "agent-instructions", + scope: "system", + label: "AGENTS.md", + content: [ + { type: "text", text: "# Repo conventions\n- Prefer `gh` CLI." }, + ], + cacheControl: "ephemeral", + }, + ], + 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..c7d5ad4a2d0 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts @@ -0,0 +1,22 @@ +import type { LaunchContext, LaunchSource } from "../types"; + +const sources: LaunchSource[] = [ + { kind: "user-prompt", text: "refactor the auth middleware" }, +]; + +export const launchContextPromptOnly: LaunchContext = { + projectId: "project-1", + sources, + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + scope: "user", + 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/types.ts b/apps/desktop/src/shared/context/types.ts new file mode 100644 index 00000000000..571b68c0887 --- /dev/null +++ b/apps/desktop/src/shared/context/types.ts @@ -0,0 +1,163 @@ +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"; text: string } + | { kind: "github-issue"; url: string } + | { kind: "github-pr"; url: string } + | { kind: "internal-task"; id: string } + | { kind: "attachment"; file: AttachmentFile } + | { kind: "agent-instructions"; path: string }; + +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). + * + * `scope` decides whether this lands in the cacheable system portion of + * the final launch spec or in the per-launch user portion. + */ +export interface ContextSection { + id: string; // stable, e.g. "issue:123" + kind: LaunchSourceKind; + scope: "system" | "user"; + label: string; + content: ContentPart[]; + cacheControl?: "ephemeral"; + 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; + fetchPullRequest: (url: string) => Promise; + fetchInternalTask: (id: string) => Promise; + readAgentInstructions: (path: string) => Promise; +} + +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; +} + +export type ContributorRegistry = { + readonly [K in LaunchSourceKind]: ContextContributor< + Extract + >; +}; + +/** + * 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"]; +} From 5751344b05e1844c445765cd082ed04efa041e4f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 14 Apr 2026 19:24:28 -0700 Subject: [PATCH 04/42] feat(desktop/context): add composer with dedup, ordering, failure tolerance Step 2 of the v2 launch-context composition plan. buildLaunchContext parallel-resolves sources via a contributor registry, dedups URL/id-kinds (attachments never dedup), preserves input order within a kind, applies the default kind group order at the end, tolerates per-source failures (populated on failures[]), and enforces a 10s per-contributor timeout. taskSlug derivation: first internal-task section wins, falling back to first github-issue. 12 tests pass. --- .../src/shared/context/composer.test.ts | 342 ++++++++++++++++++ apps/desktop/src/shared/context/composer.ts | 162 +++++++++ 2 files changed, 504 insertions(+) create mode 100644 apps/desktop/src/shared/context/composer.test.ts create mode 100644 apps/desktop/src/shared/context/composer.ts 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..53f4e0f4d2f --- /dev/null +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -0,0 +1,342 @@ +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( + kind: K, + resolver: ( + source: Extract, + ) => Promise, +): ContextContributor> { + return { + kind, + displayName: kind, + description: kind, + requiresQuery: false, + resolve: (source) => resolver(source), + }; +} + +function registry( + overrides: Partial<{ + [K in LaunchSource["kind"]]: ContextContributor< + Extract + >; + }>, +): ContributorRegistry { + const defaults: ContributorRegistry = { + "user-prompt": makeContributor("user-prompt", async (s) => ({ + id: "user-prompt", + kind: "user-prompt", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: s.text }], + })), + "github-issue": makeContributor("github-issue", async (s) => ({ + id: `issue:${s.url}`, + kind: "github-issue", + scope: "user", + 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", + scope: "user", + 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", + scope: "user", + 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", + scope: "user", + label: s.file.filename ?? "attachment", + content: [ + { + type: "file", + data: s.file.data, + mediaType: s.file.mediaType, + filename: s.file.filename, + }, + ], + })), + "agent-instructions": makeContributor("agent-instructions", async (s) => ({ + id: `agent-instructions:${s.path}`, + kind: "agent-instructions", + scope: "system", + label: s.path, + content: [{ type: "text", text: s.path }], + cacheControl: "ephemeral", + })), + }; + + 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"); + }, + readAgentInstructions: 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", + scope: "user", + 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: "agent-instructions", path: "AGENTS.md" }, + { 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", 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", + "agent-instructions", + ]); + }); + + 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", 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", 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", + scope: "user", + 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..afdecb68c86 --- /dev/null +++ b/apps/desktop/src/shared/context/composer.ts @@ -0,0 +1,162 @@ +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", + "agent-instructions", +] as const; + +export interface BuildLaunchContextDeps { + contributors: ContributorRegistry; + resolveCtx: ResolveCtx; + timeoutMs?: number; +} + +export async function buildLaunchContext( + inputs: BuildLaunchContextInputs, + deps: BuildLaunchContextDeps, +): Promise { + 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: inputs.sources, + 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 { + 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(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Contributor timeout after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }) as Promise; +} + +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 "agent-instructions": + return `agent-instructions:${source.path}`; + case "attachment": + return null; // never dedup + } +} + +function dedupeSources(sources: LaunchSource[]): LaunchSource[] { + const seen = new Set(); + 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; +} From c7169c8201ce66d63f82404b8531c1a51513ecdb Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 14 Apr 2026 19:33:55 -0700 Subject: [PATCH 05/42] feat(desktop/context): add six contributors and default registry Step 3 of the v2 launch-context composition plan. One contributor per LaunchSource kind, each with Continue.dev-style metadata (displayName, description, requiresQuery), its own co-located test file, and a consistent 404 -> null (non-fatal) pattern for fetch-based kinds: - userPrompt -- trims, returns null on empty - attachment -- file or image ContentPart by mediaType - agentInstructions -- system-scoped, cacheControl: ephemeral - githubIssue -- title + body markdown, meta.taskSlug from slug - githubPr -- title + branch + body markdown - internalTask -- title + description, meta.taskSlug Also adds composer.integration.test.ts covering the real registry end-to-end against the multi-source fixture. 41 tests green. --- .../context/composer.integration.test.ts | 85 ++++++++++++++++++ .../contributors/agentInstructions.test.ts | 69 +++++++++++++++ .../context/contributors/agentInstructions.ts | 30 +++++++ .../context/contributors/attachment.test.ts | 88 +++++++++++++++++++ .../shared/context/contributors/attachment.ts | 30 +++++++ .../context/contributors/githubIssue.test.ts | 86 ++++++++++++++++++ .../context/contributors/githubIssue.ts | 40 +++++++++ .../context/contributors/githubPr.test.ts | 77 ++++++++++++++++ .../shared/context/contributors/githubPr.ts | 41 +++++++++ .../src/shared/context/contributors/index.ts | 25 ++++++ .../context/contributors/internalTask.test.ts | 76 ++++++++++++++++ .../context/contributors/internalTask.ts | 42 +++++++++ .../context/contributors/userPrompt.test.ts | 44 ++++++++++ .../shared/context/contributors/userPrompt.ts | 22 +++++ 14 files changed, 755 insertions(+) create mode 100644 apps/desktop/src/shared/context/composer.integration.test.ts create mode 100644 apps/desktop/src/shared/context/contributors/agentInstructions.test.ts create mode 100644 apps/desktop/src/shared/context/contributors/agentInstructions.ts create mode 100644 apps/desktop/src/shared/context/contributors/attachment.test.ts create mode 100644 apps/desktop/src/shared/context/contributors/attachment.ts create mode 100644 apps/desktop/src/shared/context/contributors/githubIssue.test.ts create mode 100644 apps/desktop/src/shared/context/contributors/githubIssue.ts create mode 100644 apps/desktop/src/shared/context/contributors/githubPr.test.ts create mode 100644 apps/desktop/src/shared/context/contributors/githubPr.ts create mode 100644 apps/desktop/src/shared/context/contributors/index.ts create mode 100644 apps/desktop/src/shared/context/contributors/internalTask.test.ts create mode 100644 apps/desktop/src/shared/context/contributors/internalTask.ts create mode 100644 apps/desktop/src/shared/context/contributors/userPrompt.test.ts create mode 100644 apps/desktop/src/shared/context/contributors/userPrompt.ts 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..ccafff6f8be --- /dev/null +++ b/apps/desktop/src/shared/context/composer.integration.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { buildLaunchContext } from "./composer"; +import { defaultContributorRegistry } from "./contributors"; +import type { ResolveCtx } from "./types"; +import { + attachmentLogsTxt, + githubIssueAuthMiddleware, + githubIssueTokenRotation, + githubPrAuthRewrite, + internalTaskRefactorAuth, +} from "./__fixtures__"; + +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 }); + }, + readAgentInstructions: async () => "# Repo conventions\n- Use bun", +}; + +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", 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: "agent-instructions", path: "/worktree/AGENTS.md" }, + ], + 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", + "agent-instructions", + ]); + expect(ctx.taskSlug).toBe(internalTaskRefactorAuth.slug); + expect(ctx.sections.find((s) => s.kind === "agent-instructions")?.scope).toBe( + "system", + ); + }); + + test("missing issue is a non-fatal null (not a failure)", async () => { + const ctx = await buildLaunchContext( + { + projectId: "project-1", + sources: [ + { kind: "user-prompt", 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/contributors/agentInstructions.test.ts b/apps/desktop/src/shared/context/contributors/agentInstructions.test.ts new file mode 100644 index 00000000000..85cad39422b --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/agentInstructions.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolveCtx } from "../types"; +import { agentInstructionsContributor } from "./agentInstructions"; + +function makeCtx(read: (path: string) => Promise): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("unused"); + }, + fetchPullRequest: async () => { + throw new Error("unused"); + }, + fetchInternalTask: async () => { + throw new Error("unused"); + }, + readAgentInstructions: read, + }; +} + +describe("agentInstructionsContributor", () => { + test("metadata", () => { + expect(agentInstructionsContributor.kind).toBe("agent-instructions"); + expect(agentInstructionsContributor.requiresQuery).toBe(false); + }); + + test("reads the file and emits a cacheable system section", async () => { + const section = await agentInstructionsContributor.resolve( + { kind: "agent-instructions", path: "/repo/AGENTS.md" }, + makeCtx(async () => "# Repo rules\n- Use bun"), + ); + expect(section).toEqual({ + id: "agent-instructions:/repo/AGENTS.md", + kind: "agent-instructions", + scope: "system", + label: "AGENTS.md", + content: [{ type: "text", text: "# Repo rules\n- Use bun" }], + cacheControl: "ephemeral", + }); + }); + + test("returns null when the file is empty", async () => { + const section = await agentInstructionsContributor.resolve( + { kind: "agent-instructions", path: "/repo/AGENTS.md" }, + makeCtx(async () => " "), + ); + expect(section).toBeNull(); + }); + + test("uses basename as label even with nested paths", async () => { + const section = await agentInstructionsContributor.resolve( + { kind: "agent-instructions", path: "/a/b/c/CLAUDE.md" }, + makeCtx(async () => "content"), + ); + expect(section?.label).toBe("CLAUDE.md"); + }); + + test("propagates read errors", async () => { + await expect( + agentInstructionsContributor.resolve( + { kind: "agent-instructions", path: "/repo/AGENTS.md" }, + makeCtx(async () => { + throw new Error("ENOENT"); + }), + ), + ).rejects.toThrow("ENOENT"); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/agentInstructions.ts b/apps/desktop/src/shared/context/contributors/agentInstructions.ts new file mode 100644 index 00000000000..11707d47b41 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/agentInstructions.ts @@ -0,0 +1,30 @@ +import type { ContextContributor } from "../types"; + +function basename(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const last = normalized.split("/").filter(Boolean).pop(); + return last ?? path; +} + +export const agentInstructionsContributor: ContextContributor<{ + kind: "agent-instructions"; + path: string; +}> = { + kind: "agent-instructions", + displayName: "Agent Instructions", + description: + "Project-level conventions (AGENTS.md, CLAUDE.md) included as stable system context.", + requiresQuery: false, + async resolve(source, ctx) { + const text = (await ctx.readAgentInstructions(source.path)).trim(); + if (!text) return null; + return { + id: `agent-instructions:${source.path}`, + kind: "agent-instructions", + scope: "system", + label: basename(source.path), + content: [{ type: "text", text }], + cacheControl: "ephemeral", + }; + }, +}; 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..23f408daecb --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/attachment.test.ts @@ -0,0 +1,88 @@ +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?.scope).toBe("user"); + 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..2e36636428d --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/attachment.ts @@ -0,0 +1,30 @@ +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", + scope: "user", + 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..3eca08389ad --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHubIssueContent, ResolveCtx } from "../types"; +import { githubIssueContributor } from "./githubIssue"; + +function makeCtx( + fetchIssue: (url: string) => Promise, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue, + fetchPullRequest: async () => { + throw new Error("unused"); + }, + fetchInternalTask: async () => { + throw new Error("unused"); + }, + readAgentInstructions: 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 user-scoped section with title + body + meta", async () => { + const section = await githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => ISSUE), + ); + expect(section).toEqual({ + id: `issue:${ISSUE.number}`, + kind: "github-issue", + scope: "user", + label: `Issue #${ISSUE.number} — ${ISSUE.title}`, + content: [ + { + type: "text", + text: `# ${ISSUE.title}\n\n${ISSUE.body}`, + }, + ], + meta: { 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: "" })), + ); + expect(section?.content).toEqual([{ type: "text", text: `# ${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..2329052ea4c --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubIssue.ts @@ -0,0 +1,40 @@ +import type { ContextContributor } 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; + try { + issue = await ctx.fetchIssue(source.url); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const body = issue.body.trim(); + const text = body ? `# ${issue.title}\n\n${body}` : `# ${issue.title}`; + return { + id: `issue:${issue.number}`, + kind: "github-issue", + scope: "user", + 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..9efbf190453 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHubPullRequestContent, ResolveCtx } from "../types"; +import { githubPrContributor } from "./githubPr"; + +function makeCtx( + fetchPullRequest: (url: string) => Promise, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("unused"); + }, + fetchPullRequest, + fetchInternalTask: async () => { + throw new Error("unused"); + }, + readAgentInstructions: 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).toEqual({ + id: `pr:${PR.number}`, + kind: "github-pr", + scope: "user", + label: `PR #${PR.number} — ${PR.title}`, + content: [ + { + type: "text", + text: `# ${PR.title}\n\nBranch: \`${PR.branch}\`\n\n${PR.body}`, + }, + ], + meta: { url: PR.url }, + }); + }); + + 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: "" })), + ); + expect(section?.content).toEqual([ + { type: "text", text: `# ${PR.title}\n\nBranch: \`${PR.branch}\`` }, + ]); + }); +}); 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..f298dbb5e35 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubPr.ts @@ -0,0 +1,41 @@ +import type { ContextContributor } 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; + try { + pr = await ctx.fetchPullRequest(source.url); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const body = pr.body.trim(); + const header = `# ${pr.title}\n\nBranch: \`${pr.branch}\``; + const text = body ? `${header}\n\n${body}` : header; + return { + id: `pr:${pr.number}`, + kind: "github-pr", + scope: "user", + 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..79559f70c7c --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/index.ts @@ -0,0 +1,25 @@ +import type { ContributorRegistry } from "../types"; +import { agentInstructionsContributor } from "./agentInstructions"; +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, + "agent-instructions": agentInstructionsContributor, + "github-issue": githubIssueContributor, + "github-pr": githubPrContributor, + "internal-task": internalTaskContributor, +}; + +export { + agentInstructionsContributor, + 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..f4f7fb700dc --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/internalTask.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import type { InternalTaskContent, ResolveCtx } from "../types"; +import { internalTaskContributor } from "./internalTask"; + +function makeCtx( + fetchInternalTask: (id: string) => Promise, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("unused"); + }, + fetchPullRequest: async () => { + throw new Error("unused"); + }, + fetchInternalTask, + readAgentInstructions: async () => { + throw new Error("unused"); + }, + }; +} + +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 user section with title + description + slug meta", async () => { + const section = await internalTaskContributor.resolve( + { kind: "internal-task", id: TASK.id }, + makeCtx(async () => TASK), + ); + expect(section).toEqual({ + id: `task:${TASK.id}`, + kind: "internal-task", + scope: "user", + label: `Task ${TASK.id} — ${TASK.title}`, + content: [ + { + type: "text", + text: `# ${TASK.title}\n\n${TASK.description}`, + }, + ], + meta: { 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 })), + ); + expect(section?.content).toEqual([ + { type: "text", text: `# ${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..0efa8fbe3bf --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/internalTask.ts @@ -0,0 +1,42 @@ +import type { ContextContributor } 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; + try { + task = await ctx.fetchInternalTask(source.id); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const description = task.description?.trim() ?? ""; + const text = description + ? `# ${task.title}\n\n${description}` + : `# ${task.title}`; + return { + id: `task:${task.id}`, + kind: "internal-task", + scope: "user", + 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..83332f909de --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts @@ -0,0 +1,44 @@ +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 prompt to a user-scoped text section", async () => { + const section = await userPromptContributor.resolve( + { kind: "user-prompt", text: "refactor the auth middleware" }, + resolveCtx, + ); + expect(section).toEqual({ + id: "user-prompt", + kind: "user-prompt", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }); + }); + + test("returns null for empty prompt", async () => { + const section = await userPromptContributor.resolve( + { kind: "user-prompt", text: " " }, + resolveCtx, + ); + expect(section).toBeNull(); + }); + + test("trims surrounding whitespace", async () => { + const section = await userPromptContributor.resolve( + { kind: "user-prompt", text: " hello " }, + resolveCtx, + ); + expect(section?.content).toEqual([{ type: "text", text: "hello" }]); + }); +}); 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..9896d541c3b --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/userPrompt.ts @@ -0,0 +1,22 @@ +import type { ContextContributor } from "../types"; + +export const userPromptContributor: ContextContributor<{ + kind: "user-prompt"; + text: string; +}> = { + kind: "user-prompt", + displayName: "Prompt", + description: "The user's free-form prompt for this launch.", + requiresQuery: true, + async resolve(source) { + const text = source.text.trim(); + if (!text) return null; + return { + id: "user-prompt", + kind: "user-prompt", + scope: "user", + label: "Prompt", + content: [{ type: "text", text }], + }; + }, +}; From 6c45bf3de7af043cca9cda1828a4f20c3b9d7531 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 14 Apr 2026 19:39:25 -0700 Subject: [PATCH 06/42] feat(shared): generic renderPromptTemplate + context prompt variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of the v2 launch-context composition plan. - Extract renderPromptTemplate(template, Record) as the generic primitive; existing renderTaskPromptTemplate is now a shim (same API, same behavior — callers unchanged). - Add AGENT_CONTEXT_PROMPT_VARIABLES (userPrompt, tasks, issues, prs, attachments, agentInstructions) + getSupportedContextPromptVariables + validateContextPromptTemplate. - Ship default context templates for markdown (codex/cursor/custom) and Claude (XML-wrapped user-request) — both for system + user scopes. - Collapse runs of 3+ newlines to 2 so empty variables produce clean output. Empty-string values substitute in (not treated as missing). 16 tests green; no consumer breakage. --- .../shared/src/agent-prompt-template.test.ts | 133 +++++++++++++++++- packages/shared/src/agent-prompt-template.ts | 120 ++++++++++++++-- 2 files changed, 242 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/agent-prompt-template.test.ts b/packages/shared/src/agent-prompt-template.test.ts index 28e1b26f0d5..85ff0d62ca8 100644 --- a/packages/shared/src/agent-prompt-template.test.ts +++ b/packages/shared/src/agent-prompt-template.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; import { + AGENT_CONTEXT_PROMPT_VARIABLES, + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + getSupportedContextPromptVariables, + renderPromptTemplate, renderTaskPromptTemplate, + validateContextPromptTemplate, validateTaskPromptTemplate, } from "./agent-prompt-template"; @@ -14,7 +22,7 @@ const TASK = { labels: ["desktop"], }; -describe("renderTaskPromptTemplate", () => { +describe("renderTaskPromptTemplate (shim)", () => { test("renders placeholders with surrounding whitespace", () => { const rendered = renderTaskPromptTemplate( "Task {{ title }} / {{ slug }}", @@ -33,3 +41,126 @@ describe("validateTaskPromptTemplate", () => { }); }); }); + +describe("renderPromptTemplate (generic)", () => { + test("substitutes from a Record", () => { + 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", + "agentInstructions", + ]); + }); + + 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(6); + }); +}); + +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("claude defaults only reference known variables", () => { + expect( + validateContextPromptTemplate( + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + ).valid, + ).toBe(true); + expect( + validateContextPromptTemplate( + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ).valid, + ).toBe(true); + }); + + test("rendering the markdown user template collapses empty sections cleanly", () => { + const rendered = renderPromptTemplate(DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, { + userPrompt: "refactor auth", + tasks: "", + issues: "", + prs: "", + attachments: "", + agentInstructions: "", + }); + expect(rendered).toBe("refactor auth"); + }); + + test("rendering the claude user template wraps user-request in XML", () => { + const rendered = renderPromptTemplate( + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + { + userPrompt: "refactor auth", + tasks: "", + issues: "", + prs: "", + attachments: "", + agentInstructions: "", + }, + ); + expect(rendered).toContain(""); + expect(rendered).toContain("refactor auth"); + expect(rendered).toContain(""); + }); +}); diff --git a/packages/shared/src/agent-prompt-template.ts b/packages/shared/src/agent-prompt-template.ts index 2ce05f6a279..541320ea80e 100644 --- a/packages/shared/src/agent-prompt-template.ts +++ b/packages/shared/src/agent-prompt-template.ts @@ -1,5 +1,36 @@ 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 { + return template + .replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, rawKey: string) => { + const key = rawKey.trim(); + return variables[key] ?? match; + }) + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +// --------------------------------------------------------------------------- +// Task prompt variables (unchanged from v1 — used by the task-run flow) +// --------------------------------------------------------------------------- + export const AGENT_TASK_PROMPT_VARIABLES = [ "id", "slug", @@ -45,18 +76,15 @@ function getTaskPromptVariables(task: TaskInput): TaskPromptVariables { }; } +/** + * Shim preserved so the existing task-run flow keeps working unchanged. + * New callers should prefer `renderPromptTemplate` directly. + */ 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 renderPromptTemplate(template, getTaskPromptVariables(task)); } export function getSupportedTaskPromptVariables(): AgentTaskPromptVariable[] { @@ -67,14 +95,86 @@ 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", + "agentInstructions", +] 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 for non-Claude agents (codex, cursor, user + * custom). Markdown with the pre-rendered kind-blocks dropped in order. + */ +export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM = `{{agentInstructions}}`; + +export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER = `{{userPrompt}} + +{{tasks}} + +{{issues}} + +{{prs}} + +{{attachments}}`; + +/** + * Default context templates for Claude agents. The user-request is + * wrapped in XML to stabilize Claude's parsing. Per-kind blocks stay as + * pre-rendered markdown (users can tighten further in settings). + */ +export const DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM = `{{agentInstructions}}`; + +export const DEFAULT_CLAUDE_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), ), ), ); From 4b7773914dcaebf336f81a9d233025bf99608133 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 14 Apr 2026 23:18:33 -0700 Subject: [PATCH 07/42] feat(agents): add contextPromptTemplate {system, user} to agent configs Step 5 of the v2 launch-context composition plan. Extends the agent config surface so V2 launches can render structured context into per-agent system/user templates: - packages/shared/agent-definition: required contextPromptTemplateSystem and contextPromptTemplateUser fields on BaseAgentDefinition; createTerminalAgentDefinition fills defaults with the markdown templates from step 4. - packages/shared/builtin-terminal-agents: Claude terminal ships the Claude-XML defaults; other builtins inherit markdown defaults. - packages/shared/agent-catalog: BUILTIN_CHAT_AGENT (Claude-based superset-chat) ships the Claude-XML defaults. - packages/local-db/schema/zod: add both fields to AGENT_PRESET_FIELDS, agentPresetOverrideSchema, agentCustomDefinitionSchema (optional). - apps/desktop/shared/utils/agent-settings: thread through TERMINAL_OVERRIDE_FIELDS, CHAT_OVERRIDE_FIELDS, AgentPresetPatch, CustomAgentDefinitionPatch, resolveAgentConfig (both branches), applyCustomAgentDefinitionPatch, createOverrideEnvelopeWithPatch. - apps/desktop/test-setup: update the mocked @superset/local-db schema (the Bun test workaround for drizzle-orm/sqlite-core) so tests see the same shape as runtime. New tests: contextPromptTemplate resolution for claude terminal, codex markdown defaults, superset-chat claude defaults, terminal and chat override replacement, custom terminal fallback to markdown. 113 tests green across context + agent-settings suites. --- .../src/shared/utils/agent-settings.test.ts | 98 +++++++++++++++++++ .../src/shared/utils/agent-settings.ts | 45 +++++++++ apps/desktop/test-setup.ts | 4 + packages/local-db/src/schema/zod.ts | 6 ++ packages/shared/src/agent-catalog.ts | 8 +- packages/shared/src/agent-definition.ts | 30 +++++- .../shared/src/builtin-terminal-agents.ts | 8 +- 7 files changed, 196 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/shared/utils/agent-settings.test.ts b/apps/desktop/src/shared/utils/agent-settings.test.ts index 47128fe828f..b09248457d9 100644 --- a/apps/desktop/src/shared/utils/agent-settings.test.ts +++ b/apps/desktop/src/shared/utils/agent-settings.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; import { getBuiltinAgentDefinition } from "@superset/shared/agent-catalog"; +import { + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, +} from "@superset/shared/agent-prompt-template"; import { applyCustomAgentDefinitionPatch, createOverrideEnvelopeWithPatch, @@ -268,3 +274,95 @@ describe("custom agent definition helpers", () => { ]); }); }); + +describe("contextPromptTemplate resolution", () => { + test("claude terminal ships the Claude XML defaults", () => { + const claude = resolveAgentConfigs({}).find((p) => p.id === "claude"); + expect(claude?.contextPromptTemplateSystem).toBe( + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ); + expect(claude?.contextPromptTemplateUser).toBe( + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + ); + }); + + test("codex terminal ships the markdown defaults", () => { + const codex = resolveAgentConfigs({}).find((p) => p.id === "codex"); + expect(codex?.contextPromptTemplateSystem).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ); + expect(codex?.contextPromptTemplateUser).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + ); + }); + + test("superset-chat ships the Claude XML defaults", () => { + const chat = resolveAgentConfigs({}).find( + (p) => p.id === "superset-chat", + ); + expect(chat?.contextPromptTemplateSystem).toBe( + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ); + expect(chat?.contextPromptTemplateUser).toBe( + DEFAULT_CLAUDE_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_CLAUDE_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/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index debfb2e2657..0481cd32315 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -125,6 +125,8 @@ export const AGENT_PRESET_FIELDS = [ "promptCommand", "promptCommandSuffix", "taskPromptTemplate", + "contextPromptTemplateSystem", + "contextPromptTemplateUser", "model", ] as const; @@ -143,6 +145,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(), }); @@ -167,6 +171,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..93db8c233c4 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_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CLAUDE_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_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + contextPromptTemplateUser: DEFAULT_CLAUDE_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..1ed95ebfa15 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 { + extends Omit< + TerminalAgentDefinition, + | "promptCommand" + | "promptTransport" + | "contextPromptTemplateSystem" + | "contextPromptTemplateUser" + > { promptCommand?: string; promptTransport?: PromptTransport; + contextPromptTemplateSystem?: string; + contextPromptTemplateUser?: string; } export interface ChatAgentDefinition extends BaseAgentDefinition { @@ -41,6 +65,10 @@ 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/builtin-terminal-agents.ts b/packages/shared/src/builtin-terminal-agents.ts index 0f3932df330..f13fc4895d4 100644 --- a/packages/shared/src/builtin-terminal-agents.ts +++ b/packages/shared/src/builtin-terminal-agents.ts @@ -4,7 +4,11 @@ import { type TerminalAgentDefinitionInput, } from "./agent-definition"; import type { PromptTransport } from "./agent-prompt-launch"; -import { DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE } from "./agent-prompt-template"; +import { + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, +} from "./agent-prompt-template"; interface BuiltinTerminalAgentManifest extends Omit< @@ -64,6 +68,8 @@ export const BUILTIN_TERMINAL_AGENTS = [ "Anthropic's coding agent for reading code, editing files, and running terminal workflows.", command: "claude --dangerously-skip-permissions", includeInDefaultTerminalPresets: true, + contextPromptTemplateSystem: DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + contextPromptTemplateUser: DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, }), createBuiltinTerminalAgent({ id: "amp", From df486c44233023e73bb22f421e13afc5279825f2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 09:17:24 -0700 Subject: [PATCH 08/42] chore: biome format pass --- .../__fixtures__/attachment.logs-txt.ts | 4 +- .../launchContext.multi-source.ts | 11 ++--- .../context/composer.integration.test.ts | 12 +++--- .../context/contributors/githubIssue.test.ts | 4 +- apps/desktop/src/shared/context/types.ts | 4 +- .../src/shared/utils/agent-settings.test.ts | 4 +- packages/shared/src/agent-definition.ts | 3 +- .../shared/src/agent-prompt-template.test.ts | 43 +++++++++++-------- 8 files changed, 46 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts b/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts index 3efbd1502d8..4fd7729fdc6 100644 --- a/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts +++ b/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts @@ -1,7 +1,9 @@ import type { AttachmentFile } from "../types"; export const attachmentLogsTxt: AttachmentFile = { - data: new TextEncoder().encode("2026-04-14 ERROR auth.ts:42 token decrypt failed\n"), + data: new TextEncoder().encode( + "2026-04-14 ERROR auth.ts:42 token decrypt failed\n", + ), mediaType: "text/plain", filename: "logs.txt", }; diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts index 0e07ae59000..994fc40f7cd 100644 --- a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -3,7 +3,10 @@ import { attachmentLogsTxt, attachmentScreenshotPng, } from "./attachment.logs-txt"; -import { githubIssueAuthMiddleware, githubIssueTokenRotation } from "./githubIssue.auth-middleware"; +import { + githubIssueAuthMiddleware, + githubIssueTokenRotation, +} from "./githubIssue.auth-middleware"; import { githubPrAuthRewrite } from "./githubPr.auth-rewrite"; import { internalTaskRefactorAuth } from "./internalTask.refactor-auth"; @@ -37,8 +40,7 @@ export const launchContextMultiSource: LaunchContext = { content: [ { type: "text", - text: - "Split session-token storage from request handling so we can encrypt at rest.", + text: "Split session-token storage from request handling so we can encrypt at rest.", }, ], meta: { taskSlug: internalTaskRefactorAuth.slug }, @@ -51,8 +53,7 @@ export const launchContextMultiSource: LaunchContext = { content: [ { type: "text", - text: - "Legal flagged this. Sessions written to disk without encryption.", + text: "Legal flagged this. Sessions written to disk without encryption.", }, ], meta: { diff --git a/apps/desktop/src/shared/context/composer.integration.test.ts b/apps/desktop/src/shared/context/composer.integration.test.ts index ccafff6f8be..e753df22f61 100644 --- a/apps/desktop/src/shared/context/composer.integration.test.ts +++ b/apps/desktop/src/shared/context/composer.integration.test.ts @@ -1,7 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { buildLaunchContext } from "./composer"; -import { defaultContributorRegistry } from "./contributors"; -import type { ResolveCtx } from "./types"; import { attachmentLogsTxt, githubIssueAuthMiddleware, @@ -9,6 +6,9 @@ import { githubPrAuthRewrite, internalTaskRefactorAuth, } from "./__fixtures__"; +import { buildLaunchContext } from "./composer"; +import { defaultContributorRegistry } from "./contributors"; +import type { ResolveCtx } from "./types"; const resolveCtx: ResolveCtx = { projectId: "project-1", @@ -59,9 +59,9 @@ describe("composer + default registry (integration)", () => { "agent-instructions", ]); expect(ctx.taskSlug).toBe(internalTaskRefactorAuth.slug); - expect(ctx.sections.find((s) => s.kind === "agent-instructions")?.scope).toBe( - "system", - ); + expect( + ctx.sections.find((s) => s.kind === "agent-instructions")?.scope, + ).toBe("system"); }); test("missing issue is a non-fatal null (not a failure)", async () => { diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts index 3eca08389ad..21f7e8ce94a 100644 --- a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts @@ -81,6 +81,8 @@ describe("githubIssueContributor", () => { { kind: "github-issue", url: ISSUE.url }, makeCtx(async () => ({ ...ISSUE, body: "" })), ); - expect(section?.content).toEqual([{ type: "text", text: `# ${ISSUE.title}` }]); + expect(section?.content).toEqual([ + { type: "text", text: `# ${ISSUE.title}` }, + ]); }); }); diff --git a/apps/desktop/src/shared/context/types.ts b/apps/desktop/src/shared/context/types.ts index 571b68c0887..79b222b480a 100644 --- a/apps/desktop/src/shared/context/types.ts +++ b/apps/desktop/src/shared/context/types.ts @@ -104,9 +104,7 @@ export interface InternalTaskContent { * Metadata (displayName/description/requiresQuery) is lifted from * Continue.dev's context provider interface for future UI rendering. */ -export interface ContextContributor< - S extends LaunchSource = LaunchSource, -> { +export interface ContextContributor { kind: S["kind"]; displayName: string; description: string; diff --git a/apps/desktop/src/shared/utils/agent-settings.test.ts b/apps/desktop/src/shared/utils/agent-settings.test.ts index b09248457d9..60d1be18345 100644 --- a/apps/desktop/src/shared/utils/agent-settings.test.ts +++ b/apps/desktop/src/shared/utils/agent-settings.test.ts @@ -297,9 +297,7 @@ describe("contextPromptTemplate resolution", () => { }); test("superset-chat ships the Claude XML defaults", () => { - const chat = resolveAgentConfigs({}).find( - (p) => p.id === "superset-chat", - ); + const chat = resolveAgentConfigs({}).find((p) => p.id === "superset-chat"); expect(chat?.contextPromptTemplateSystem).toBe( DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, ); diff --git a/packages/shared/src/agent-definition.ts b/packages/shared/src/agent-definition.ts index 1ed95ebfa15..59002ad41e7 100644 --- a/packages/shared/src/agent-definition.ts +++ b/packages/shared/src/agent-definition.ts @@ -66,7 +66,8 @@ export function createTerminalAgentDefinition( promptCommand: input.promptCommand ?? input.command, promptTransport: input.promptTransport ?? "argv", contextPromptTemplateSystem: - input.contextPromptTemplateSystem ?? DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + 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 85ff0d62ca8..2b5261d679d 100644 --- a/packages/shared/src/agent-prompt-template.test.ts +++ b/packages/shared/src/agent-prompt-template.test.ts @@ -52,9 +52,9 @@ describe("renderPromptTemplate (generic)", () => { }); test("tolerates whitespace inside braces", () => { - expect(renderPromptTemplate("{{ foo }} {{ bar }}", { foo: "a", bar: "b" })).toBe( - "a b", - ); + expect( + renderPromptTemplate("{{ foo }} {{ bar }}", { foo: "a", bar: "b" }), + ).toBe("a b"); }); test("leaves unknown placeholders intact", () => { @@ -105,10 +105,12 @@ describe("validateContextPromptTemplate", () => { }); test("flags unknown variables", () => { - expect(validateContextPromptTemplate("{{slackThread}} {{issues}}")).toEqual({ - valid: false, - unknownVariables: ["slackThread"], - }); + expect(validateContextPromptTemplate("{{slackThread}} {{issues}}")).toEqual( + { + valid: false, + unknownVariables: ["slackThread"], + }, + ); }); }); @@ -118,15 +120,15 @@ describe("default context templates", () => { validateContextPromptTemplate(DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER).valid, ).toBe(true); expect( - validateContextPromptTemplate(DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM).valid, + validateContextPromptTemplate(DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM) + .valid, ).toBe(true); }); test("claude defaults only reference known variables", () => { expect( - validateContextPromptTemplate( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, - ).valid, + validateContextPromptTemplate(DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER) + .valid, ).toBe(true); expect( validateContextPromptTemplate( @@ -136,14 +138,17 @@ describe("default context templates", () => { }); test("rendering the markdown user template collapses empty sections cleanly", () => { - const rendered = renderPromptTemplate(DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, { - userPrompt: "refactor auth", - tasks: "", - issues: "", - prs: "", - attachments: "", - agentInstructions: "", - }); + const rendered = renderPromptTemplate( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + { + userPrompt: "refactor auth", + tasks: "", + issues: "", + prs: "", + attachments: "", + agentInstructions: "", + }, + ); expect(rendered).toBe("refactor auth"); }); From df6b834dfd6dd4c381dc64239c6e7f638b5a181a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 09:29:59 -0700 Subject: [PATCH 09/42] feat(desktop/context): user-prompt source takes ContentPart[] (multimodal) Future-proofs the user-prompt LaunchSource for an eventual rich-editor input (interleaved text, inline images, inline files). The rest of the pipeline was already ContentPart[]-native; this removes the last narrow string-only call site. - LaunchSource["user-prompt"]: { text: string } -> { content: ContentPart[] } - userPromptContributor: normalizes (drops empty text parts, trims bookend whitespace), returns null when nothing remains, passes file/image parts through untouched. - Adds userPromptFromText(text) helper for plain-string callers so modal/cli/task flows don't repeat the [{ type: "text", text }] boilerplate. - Three new tests: multimodal text+image+text preservation, whitespace-only content returns null, empty text parts dropped between non-empty ones. --- .../launchContext.multi-source.ts | 5 +- .../__fixtures__/launchContext.prompt-only.ts | 5 +- .../context/composer.integration.test.ts | 7 +- .../src/shared/context/composer.test.ts | 6 +- .../context/contributors/githubIssue.ts | 4 +- .../shared/context/contributors/githubPr.ts | 4 +- .../context/contributors/internalTask.ts | 4 +- .../context/contributors/userPrompt.test.ts | 70 +++++++++++++++++-- .../shared/context/contributors/userPrompt.ts | 53 ++++++++++++-- apps/desktop/src/shared/context/types.ts | 2 +- 10 files changed, 135 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts index 994fc40f7cd..4dd2170f935 100644 --- a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -11,7 +11,10 @@ import { githubPrAuthRewrite } from "./githubPr.auth-rewrite"; import { internalTaskRefactorAuth } from "./internalTask.refactor-auth"; const sources: LaunchSource[] = [ - { kind: "user-prompt", text: "refactor the auth middleware" }, + { + 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 }, diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts index c7d5ad4a2d0..5053fa6c343 100644 --- a/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts @@ -1,7 +1,10 @@ import type { LaunchContext, LaunchSource } from "../types"; const sources: LaunchSource[] = [ - { kind: "user-prompt", text: "refactor the auth middleware" }, + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, ]; export const launchContextPromptOnly: LaunchContext = { diff --git a/apps/desktop/src/shared/context/composer.integration.test.ts b/apps/desktop/src/shared/context/composer.integration.test.ts index e753df22f61..d27c22087ef 100644 --- a/apps/desktop/src/shared/context/composer.integration.test.ts +++ b/apps/desktop/src/shared/context/composer.integration.test.ts @@ -35,7 +35,10 @@ describe("composer + default registry (integration)", () => { { projectId: "project-1", sources: [ - { kind: "user-prompt", text: "refactor the auth middleware" }, + { + 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 }, @@ -69,7 +72,7 @@ describe("composer + default registry (integration)", () => { { projectId: "project-1", sources: [ - { kind: "user-prompt", text: "hi" }, + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, { kind: "github-issue", url: "https://github.com/acme/repo/issues/99999", diff --git a/apps/desktop/src/shared/context/composer.test.ts b/apps/desktop/src/shared/context/composer.test.ts index 53f4e0f4d2f..4eb811e77df 100644 --- a/apps/desktop/src/shared/context/composer.test.ts +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -183,7 +183,7 @@ describe("buildLaunchContext", () => { }, { kind: "github-issue", url: "https://x/issues/1" }, { kind: "internal-task", id: "T-1" }, - { kind: "user-prompt", text: "hi" }, + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, ], agent: { id: "none" }, }, @@ -234,7 +234,7 @@ describe("buildLaunchContext", () => { const ctx = await buildLaunchContext( { projectId: "p", - sources: [{ kind: "user-prompt", text: "hi" }], + sources: [{ kind: "user-prompt", content: [{ type: "text", text: "hi" }] }], agent: { id: "none" }, }, { contributors: registry({}), resolveCtx }, @@ -249,7 +249,7 @@ describe("buildLaunchContext", () => { sources: [ { kind: "github-issue", url: "https://x/issues/1" }, { kind: "github-issue", url: "https://x/issues/2" }, - { kind: "user-prompt", text: "hi" }, + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, ], agent: { id: "none" }, }, diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.ts b/apps/desktop/src/shared/context/contributors/githubIssue.ts index 2329052ea4c..88f86737232 100644 --- a/apps/desktop/src/shared/context/contributors/githubIssue.ts +++ b/apps/desktop/src/shared/context/contributors/githubIssue.ts @@ -1,4 +1,4 @@ -import type { ContextContributor } from "../types"; +import type { ContextContributor, GitHubIssueContent } from "../types"; function isNotFound(err: unknown): boolean { return ( @@ -18,7 +18,7 @@ export const githubIssueContributor: ContextContributor<{ description: "Full issue body fetched and inlined as context.", requiresQuery: true, async resolve(source, ctx) { - let issue; + let issue: GitHubIssueContent; try { issue = await ctx.fetchIssue(source.url); } catch (err) { diff --git a/apps/desktop/src/shared/context/contributors/githubPr.ts b/apps/desktop/src/shared/context/contributors/githubPr.ts index f298dbb5e35..4fb564d2bd9 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.ts @@ -1,4 +1,4 @@ -import type { ContextContributor } from "../types"; +import type { ContextContributor, GitHubPullRequestContent } from "../types"; function isNotFound(err: unknown): boolean { return ( @@ -18,7 +18,7 @@ export const githubPrContributor: ContextContributor<{ description: "Full PR metadata fetched and inlined as context.", requiresQuery: true, async resolve(source, ctx) { - let pr; + let pr: GitHubPullRequestContent; try { pr = await ctx.fetchPullRequest(source.url); } catch (err) { diff --git a/apps/desktop/src/shared/context/contributors/internalTask.ts b/apps/desktop/src/shared/context/contributors/internalTask.ts index 0efa8fbe3bf..904fb2cee37 100644 --- a/apps/desktop/src/shared/context/contributors/internalTask.ts +++ b/apps/desktop/src/shared/context/contributors/internalTask.ts @@ -1,4 +1,4 @@ -import type { ContextContributor } from "../types"; +import type { ContextContributor, InternalTaskContent } from "../types"; function isNotFound(err: unknown): boolean { return ( @@ -18,7 +18,7 @@ export const internalTaskContributor: ContextContributor<{ description: "Internal task spec inlined as context.", requiresQuery: true, async resolve(source, ctx) { - let task; + let task: InternalTaskContent; try { task = await ctx.fetchInternalTask(source.id); } catch (err) { diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.test.ts b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts index 83332f909de..784ef241e0f 100644 --- a/apps/desktop/src/shared/context/contributors/userPrompt.test.ts +++ b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts @@ -12,9 +12,12 @@ describe("userPromptContributor", () => { expect(userPromptContributor.requiresQuery).toBe(true); }); - test("resolves a prompt to a user-scoped text section", async () => { + test("resolves a single text part", async () => { const section = await userPromptContributor.resolve( - { kind: "user-prompt", text: "refactor the auth middleware" }, + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, resolveCtx, ); expect(section).toEqual({ @@ -26,19 +29,74 @@ describe("userPromptContributor", () => { }); }); - test("returns null for empty prompt", async () => { + test("returns null for empty content", async () => { const section = await userPromptContributor.resolve( - { kind: "user-prompt", text: " " }, + { kind: "user-prompt", content: [] }, resolveCtx, ); expect(section).toBeNull(); }); - test("trims surrounding whitespace", async () => { + test("returns null when only whitespace text parts are present", async () => { const section = await userPromptContributor.resolve( - { kind: "user-prompt", text: " hello " }, + { + 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 index 9896d541c3b..1f7ef11315b 100644 --- a/apps/desktop/src/shared/context/contributors/userPrompt.ts +++ b/apps/desktop/src/shared/context/contributors/userPrompt.ts @@ -1,22 +1,65 @@ -import type { ContextContributor } from "../types"; +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 { + 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"; - text: string; + content: ContentPart[]; }> = { kind: "user-prompt", displayName: "Prompt", description: "The user's free-form prompt for this launch.", requiresQuery: true, async resolve(source) { - const text = source.text.trim(); - if (!text) return null; + const content = normalize(source.content); + if (content.length === 0) return null; return { id: "user-prompt", kind: "user-prompt", scope: "user", label: "Prompt", - content: [{ type: "text", text }], + content, }; }, }; diff --git a/apps/desktop/src/shared/context/types.ts b/apps/desktop/src/shared/context/types.ts index 79b222b480a..4e369b6b3c2 100644 --- a/apps/desktop/src/shared/context/types.ts +++ b/apps/desktop/src/shared/context/types.ts @@ -8,7 +8,7 @@ import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; * contributor, register it. */ export type LaunchSource = - | { kind: "user-prompt"; text: string } + | { kind: "user-prompt"; content: ContentPart[] } | { kind: "github-issue"; url: string } | { kind: "github-pr"; url: string } | { kind: "internal-task"; id: string } From 8075a63cf9c1bcbbc19026fd88475f923d13edbc Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 10:16:38 -0700 Subject: [PATCH 10/42] =?UTF-8?q?refactor(desktop/context):=20drop=20agent?= =?UTF-8?q?-instructions=20source=20=E2=80=94=20harnesses=20handle=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent harnesses (Claude CLI, Codex, Cursor Agent) discover their own conventions files natively from the worktree — no injection needed from our side. V1 confirms: zero references to AGENTS.md/CLAUDE.md as injected context. Only the agent itself reads them. Removing this also gets us closer to the "no Electron IPC in V2" rule — the composer no longer needs to read files from disk. - Drop {kind: "agent-instructions"} from LaunchSource union. - Delete agentInstructionsContributor + its test. - Remove readAgentInstructions from ResolveCtx; update the three contributor test stubs that referenced it. - Drop "agent-instructions" from the composer KIND_ORDER and sourceIdentity switch. - Drop the AGENTS.md sample from the multi-source fixture + the composer integration test. - Remove "agentInstructions" from AGENT_CONTEXT_PROMPT_VARIABLES. - System default templates are now empty strings (chat agents get no system context yet; future host-service-backed path can fill later). 54 tests green across context + agent-settings; 16 green in shared. --- .../launchContext.multi-source.ts | 11 --- .../context/composer.integration.test.ts | 6 -- .../src/shared/context/composer.test.ts | 13 ---- apps/desktop/src/shared/context/composer.ts | 3 - .../contributors/agentInstructions.test.ts | 69 ------------------- .../context/contributors/agentInstructions.ts | 30 -------- .../context/contributors/githubIssue.test.ts | 3 - .../context/contributors/githubPr.test.ts | 3 - .../src/shared/context/contributors/index.ts | 3 - .../context/contributors/internalTask.test.ts | 3 - apps/desktop/src/shared/context/types.ts | 4 +- .../shared/src/agent-prompt-template.test.ts | 5 +- packages/shared/src/agent-prompt-template.ts | 8 ++- 13 files changed, 7 insertions(+), 154 deletions(-) delete mode 100644 apps/desktop/src/shared/context/contributors/agentInstructions.test.ts delete mode 100644 apps/desktop/src/shared/context/contributors/agentInstructions.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts index 4dd2170f935..ce5990ba0db 100644 --- a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -21,7 +21,6 @@ const sources: LaunchSource[] = [ { kind: "github-pr", url: githubPrAuthRewrite.url }, { kind: "attachment", file: attachmentLogsTxt }, { kind: "attachment", file: attachmentScreenshotPng }, - { kind: "agent-instructions", path: "/worktree/AGENTS.md" }, ]; export const launchContextMultiSource: LaunchContext = { @@ -115,16 +114,6 @@ export const launchContextMultiSource: LaunchContext = { }, ], }, - { - id: "agent-instructions:/worktree/AGENTS.md", - kind: "agent-instructions", - scope: "system", - label: "AGENTS.md", - content: [ - { type: "text", text: "# Repo conventions\n- Prefer `gh` CLI." }, - ], - cacheControl: "ephemeral", - }, ], failures: [], taskSlug: internalTaskRefactorAuth.slug, diff --git a/apps/desktop/src/shared/context/composer.integration.test.ts b/apps/desktop/src/shared/context/composer.integration.test.ts index d27c22087ef..f9c056d0087 100644 --- a/apps/desktop/src/shared/context/composer.integration.test.ts +++ b/apps/desktop/src/shared/context/composer.integration.test.ts @@ -26,7 +26,6 @@ const resolveCtx: ResolveCtx = { if (id === internalTaskRefactorAuth.id) return internalTaskRefactorAuth; throw Object.assign(new Error("not found"), { status: 404 }); }, - readAgentInstructions: async () => "# Repo conventions\n- Use bun", }; describe("composer + default registry (integration)", () => { @@ -44,7 +43,6 @@ describe("composer + default registry (integration)", () => { { kind: "github-issue", url: githubIssueTokenRotation.url }, { kind: "github-pr", url: githubPrAuthRewrite.url }, { kind: "attachment", file: attachmentLogsTxt }, - { kind: "agent-instructions", path: "/worktree/AGENTS.md" }, ], agent: { id: "claude" }, }, @@ -59,12 +57,8 @@ describe("composer + default registry (integration)", () => { "github-issue", "github-pr", "attachment", - "agent-instructions", ]); expect(ctx.taskSlug).toBe(internalTaskRefactorAuth.slug); - expect( - ctx.sections.find((s) => s.kind === "agent-instructions")?.scope, - ).toBe("system"); }); test("missing issue is a non-fatal null (not a failure)", async () => { diff --git a/apps/desktop/src/shared/context/composer.test.ts b/apps/desktop/src/shared/context/composer.test.ts index 4eb811e77df..d0242b20a11 100644 --- a/apps/desktop/src/shared/context/composer.test.ts +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -76,14 +76,6 @@ function registry( }, ], })), - "agent-instructions": makeContributor("agent-instructions", async (s) => ({ - id: `agent-instructions:${s.path}`, - kind: "agent-instructions", - scope: "system", - label: s.path, - content: [{ type: "text", text: s.path }], - cacheControl: "ephemeral", - })), }; return { ...defaults, ...overrides }; @@ -101,9 +93,6 @@ const resolveCtx: ResolveCtx = { fetchInternalTask: async () => { throw new Error("not used in tests"); }, - readAgentInstructions: async () => { - throw new Error("not used in tests"); - }, }; describe("buildLaunchContext", () => { @@ -171,7 +160,6 @@ describe("buildLaunchContext", () => { { projectId: "p", sources: [ - { kind: "agent-instructions", path: "AGENTS.md" }, { kind: "github-pr", url: "https://x/pull/1" }, { kind: "attachment", @@ -195,7 +183,6 @@ describe("buildLaunchContext", () => { "github-issue", "github-pr", "attachment", - "agent-instructions", ]); }); diff --git a/apps/desktop/src/shared/context/composer.ts b/apps/desktop/src/shared/context/composer.ts index afdecb68c86..aa4388320f0 100644 --- a/apps/desktop/src/shared/context/composer.ts +++ b/apps/desktop/src/shared/context/composer.ts @@ -20,7 +20,6 @@ const KIND_ORDER: readonly LaunchSourceKind[] = [ "github-issue", "github-pr", "attachment", - "agent-instructions", ] as const; export interface BuildLaunchContextDeps { @@ -130,8 +129,6 @@ function sourceIdentity(source: LaunchSource): string | null { return `github-pr:${source.url}`; case "internal-task": return `internal-task:${source.id}`; - case "agent-instructions": - return `agent-instructions:${source.path}`; case "attachment": return null; // never dedup } diff --git a/apps/desktop/src/shared/context/contributors/agentInstructions.test.ts b/apps/desktop/src/shared/context/contributors/agentInstructions.test.ts deleted file mode 100644 index 85cad39422b..00000000000 --- a/apps/desktop/src/shared/context/contributors/agentInstructions.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ResolveCtx } from "../types"; -import { agentInstructionsContributor } from "./agentInstructions"; - -function makeCtx(read: (path: string) => Promise): ResolveCtx { - return { - projectId: "p", - signal: new AbortController().signal, - fetchIssue: async () => { - throw new Error("unused"); - }, - fetchPullRequest: async () => { - throw new Error("unused"); - }, - fetchInternalTask: async () => { - throw new Error("unused"); - }, - readAgentInstructions: read, - }; -} - -describe("agentInstructionsContributor", () => { - test("metadata", () => { - expect(agentInstructionsContributor.kind).toBe("agent-instructions"); - expect(agentInstructionsContributor.requiresQuery).toBe(false); - }); - - test("reads the file and emits a cacheable system section", async () => { - const section = await agentInstructionsContributor.resolve( - { kind: "agent-instructions", path: "/repo/AGENTS.md" }, - makeCtx(async () => "# Repo rules\n- Use bun"), - ); - expect(section).toEqual({ - id: "agent-instructions:/repo/AGENTS.md", - kind: "agent-instructions", - scope: "system", - label: "AGENTS.md", - content: [{ type: "text", text: "# Repo rules\n- Use bun" }], - cacheControl: "ephemeral", - }); - }); - - test("returns null when the file is empty", async () => { - const section = await agentInstructionsContributor.resolve( - { kind: "agent-instructions", path: "/repo/AGENTS.md" }, - makeCtx(async () => " "), - ); - expect(section).toBeNull(); - }); - - test("uses basename as label even with nested paths", async () => { - const section = await agentInstructionsContributor.resolve( - { kind: "agent-instructions", path: "/a/b/c/CLAUDE.md" }, - makeCtx(async () => "content"), - ); - expect(section?.label).toBe("CLAUDE.md"); - }); - - test("propagates read errors", async () => { - await expect( - agentInstructionsContributor.resolve( - { kind: "agent-instructions", path: "/repo/AGENTS.md" }, - makeCtx(async () => { - throw new Error("ENOENT"); - }), - ), - ).rejects.toThrow("ENOENT"); - }); -}); diff --git a/apps/desktop/src/shared/context/contributors/agentInstructions.ts b/apps/desktop/src/shared/context/contributors/agentInstructions.ts deleted file mode 100644 index 11707d47b41..00000000000 --- a/apps/desktop/src/shared/context/contributors/agentInstructions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ContextContributor } from "../types"; - -function basename(path: string): string { - const normalized = path.replace(/\\/g, "/"); - const last = normalized.split("/").filter(Boolean).pop(); - return last ?? path; -} - -export const agentInstructionsContributor: ContextContributor<{ - kind: "agent-instructions"; - path: string; -}> = { - kind: "agent-instructions", - displayName: "Agent Instructions", - description: - "Project-level conventions (AGENTS.md, CLAUDE.md) included as stable system context.", - requiresQuery: false, - async resolve(source, ctx) { - const text = (await ctx.readAgentInstructions(source.path)).trim(); - if (!text) return null; - return { - id: `agent-instructions:${source.path}`, - kind: "agent-instructions", - scope: "system", - label: basename(source.path), - content: [{ type: "text", text }], - cacheControl: "ephemeral", - }; - }, -}; diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts index 21f7e8ce94a..de6f50be920 100644 --- a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts @@ -15,9 +15,6 @@ function makeCtx( fetchInternalTask: async () => { throw new Error("unused"); }, - readAgentInstructions: async () => { - throw new Error("unused"); - }, }; } diff --git a/apps/desktop/src/shared/context/contributors/githubPr.test.ts b/apps/desktop/src/shared/context/contributors/githubPr.test.ts index 9efbf190453..076146d4ec2 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -15,9 +15,6 @@ function makeCtx( fetchInternalTask: async () => { throw new Error("unused"); }, - readAgentInstructions: async () => { - throw new Error("unused"); - }, }; } diff --git a/apps/desktop/src/shared/context/contributors/index.ts b/apps/desktop/src/shared/context/contributors/index.ts index 79559f70c7c..ee9b02e42ca 100644 --- a/apps/desktop/src/shared/context/contributors/index.ts +++ b/apps/desktop/src/shared/context/contributors/index.ts @@ -1,5 +1,4 @@ import type { ContributorRegistry } from "../types"; -import { agentInstructionsContributor } from "./agentInstructions"; import { attachmentContributor } from "./attachment"; import { githubIssueContributor } from "./githubIssue"; import { githubPrContributor } from "./githubPr"; @@ -9,14 +8,12 @@ import { userPromptContributor } from "./userPrompt"; export const defaultContributorRegistry: ContributorRegistry = { "user-prompt": userPromptContributor, attachment: attachmentContributor, - "agent-instructions": agentInstructionsContributor, "github-issue": githubIssueContributor, "github-pr": githubPrContributor, "internal-task": internalTaskContributor, }; export { - agentInstructionsContributor, attachmentContributor, githubIssueContributor, githubPrContributor, diff --git a/apps/desktop/src/shared/context/contributors/internalTask.test.ts b/apps/desktop/src/shared/context/contributors/internalTask.test.ts index f4f7fb700dc..baea7f39c42 100644 --- a/apps/desktop/src/shared/context/contributors/internalTask.test.ts +++ b/apps/desktop/src/shared/context/contributors/internalTask.test.ts @@ -15,9 +15,6 @@ function makeCtx( throw new Error("unused"); }, fetchInternalTask, - readAgentInstructions: async () => { - throw new Error("unused"); - }, }; } diff --git a/apps/desktop/src/shared/context/types.ts b/apps/desktop/src/shared/context/types.ts index 4e369b6b3c2..3bc366199a0 100644 --- a/apps/desktop/src/shared/context/types.ts +++ b/apps/desktop/src/shared/context/types.ts @@ -12,8 +12,7 @@ export type LaunchSource = | { kind: "github-issue"; url: string } | { kind: "github-pr"; url: string } | { kind: "internal-task"; id: string } - | { kind: "attachment"; file: AttachmentFile } - | { kind: "agent-instructions"; path: string }; + | { kind: "attachment"; file: AttachmentFile }; export type LaunchSourceKind = LaunchSource["kind"]; @@ -73,7 +72,6 @@ export interface ResolveCtx { fetchIssue: (url: string) => Promise; fetchPullRequest: (url: string) => Promise; fetchInternalTask: (id: string) => Promise; - readAgentInstructions: (path: string) => Promise; } export interface GitHubIssueContent { diff --git a/packages/shared/src/agent-prompt-template.test.ts b/packages/shared/src/agent-prompt-template.test.ts index 2b5261d679d..2badff41a48 100644 --- a/packages/shared/src/agent-prompt-template.test.ts +++ b/packages/shared/src/agent-prompt-template.test.ts @@ -84,7 +84,6 @@ describe("context prompt variables", () => { "issues", "prs", "attachments", - "agentInstructions", ]); }); @@ -92,7 +91,7 @@ describe("context prompt variables", () => { const vars = getSupportedContextPromptVariables(); expect(vars).toEqual([...AGENT_CONTEXT_PROMPT_VARIABLES]); vars.push("mutated" as never); - expect(AGENT_CONTEXT_PROMPT_VARIABLES).toHaveLength(6); + expect(AGENT_CONTEXT_PROMPT_VARIABLES).toHaveLength(5); }); }); @@ -146,7 +145,6 @@ describe("default context templates", () => { issues: "", prs: "", attachments: "", - agentInstructions: "", }, ); expect(rendered).toBe("refactor auth"); @@ -161,7 +159,6 @@ describe("default context templates", () => { issues: "", prs: "", attachments: "", - agentInstructions: "", }, ); expect(rendered).toContain(""); diff --git a/packages/shared/src/agent-prompt-template.ts b/packages/shared/src/agent-prompt-template.ts index 541320ea80e..a4b4e54e8a9 100644 --- a/packages/shared/src/agent-prompt-template.ts +++ b/packages/shared/src/agent-prompt-template.ts @@ -108,7 +108,6 @@ export const AGENT_CONTEXT_PROMPT_VARIABLES = [ "issues", "prs", "attachments", - "agentInstructions", ] as const; export type AgentContextPromptVariable = @@ -128,8 +127,11 @@ export function validateContextPromptTemplate(template: string): { /** * Default context templates for non-Claude agents (codex, cursor, user * custom). Markdown with the pre-rendered kind-blocks dropped in order. + * + * 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 = `{{agentInstructions}}`; +export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM = ""; export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER = `{{userPrompt}} @@ -146,7 +148,7 @@ export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER = `{{userPrompt}} * wrapped in XML to stabilize Claude's parsing. Per-kind blocks stay as * pre-rendered markdown (users can tighten further in settings). */ -export const DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM = `{{agentInstructions}}`; +export const DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM = ""; export const DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER = ` {{userPrompt}} From a2cfd1018f8039e655a462dbaf2e7d33318b494c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 10:23:29 -0700 Subject: [PATCH 11/42] feat(desktop/context): add buildLaunchSpec (LaunchContext -> AgentLaunchSpec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 of the v2 launch-context composition plan. buildLaunchSpec(ctx, agentConfig): - Returns null for "none" agent or missing config (V1-parity semantics). - Pre-renders per-kind markdown sub-blocks ({{tasks}}/{{issues}}/{{prs}}/ {{attachments}}) and a flattened {{userPrompt}} text variable from the LaunchContext sections. - Fills the agent's contextPromptTemplate{System,User} (from step 5) into ContentPart[] arrays. - Collects non-text parts (attachment-kind files/images + inline non-text parts from user-prompt) into the structured attachments[] field — chat agents see them as proper content parts, terminal adapters will flatten to disk refs in step 7. Also fixes the multi-source fixture to match what contributors actually emit (`# Title\n\nBody` markdown bodies) so the new snapshot tests exercise a realistic LaunchContext shape. 15 tests green (2 inline snapshots for Claude-XML + codex-markdown rendering of the canonical multi-source fixture). 69 tests green total across context + agent-settings. --- .../launchContext.multi-source.ts | 21 +- .../shared/context/buildLaunchSpec.test.ts | 422 ++++++++++++++++++ .../src/shared/context/buildLaunchSpec.ts | 112 +++++ 3 files changed, 547 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/shared/context/buildLaunchSpec.test.ts create mode 100644 apps/desktop/src/shared/context/buildLaunchSpec.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts index ce5990ba0db..cc85b70d5a0 100644 --- a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -38,11 +38,11 @@ export const launchContextMultiSource: LaunchContext = { id: `task:${internalTaskRefactorAuth.id}`, kind: "internal-task", scope: "user", - label: "Task TASK-42 — Refactor auth middleware", + label: `Task ${internalTaskRefactorAuth.id} — ${internalTaskRefactorAuth.title}`, content: [ { type: "text", - text: "Split session-token storage from request handling so we can encrypt at rest.", + text: `# ${internalTaskRefactorAuth.title}\n\n${internalTaskRefactorAuth.description}`, }, ], meta: { taskSlug: internalTaskRefactorAuth.slug }, @@ -51,11 +51,11 @@ export const launchContextMultiSource: LaunchContext = { id: `issue:${githubIssueAuthMiddleware.number}`, kind: "github-issue", scope: "user", - label: "Issue #123 — Auth middleware stores tokens in plaintext", + label: `Issue #${githubIssueAuthMiddleware.number} — ${githubIssueAuthMiddleware.title}`, content: [ { type: "text", - text: "Legal flagged this. Sessions written to disk without encryption.", + text: `# ${githubIssueAuthMiddleware.title}\n\n${githubIssueAuthMiddleware.body}`, }, ], meta: { @@ -67,8 +67,13 @@ export const launchContextMultiSource: LaunchContext = { id: `issue:${githubIssueTokenRotation.number}`, kind: "github-issue", scope: "user", - label: "Issue #124 — Rotate session tokens on password change", - content: [{ type: "text", text: "Follow-up for #123." }], + label: `Issue #${githubIssueTokenRotation.number} — ${githubIssueTokenRotation.title}`, + content: [ + { + type: "text", + text: `# ${githubIssueTokenRotation.title}\n\n${githubIssueTokenRotation.body}`, + }, + ], meta: { url: githubIssueTokenRotation.url, taskSlug: githubIssueTokenRotation.slug, @@ -78,11 +83,11 @@ export const launchContextMultiSource: LaunchContext = { id: `pr:${githubPrAuthRewrite.number}`, kind: "github-pr", scope: "user", - label: "PR #200 — Rewrite auth middleware", + label: `PR #${githubPrAuthRewrite.number} — ${githubPrAuthRewrite.title}`, content: [ { type: "text", - text: "Replaces plaintext token storage with encrypted KV.", + text: `# ${githubPrAuthRewrite.title}\n\nBranch: \`${githubPrAuthRewrite.branch}\`\n\n${githubPrAuthRewrite.body}`, }, ], meta: { url: githubPrAuthRewrite.url }, 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..4e8098fa33c --- /dev/null +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, test } from "bun:test"; +import { + indexResolvedAgentConfigs, + resolveAgentConfigs, + type ResolvedAgentConfig, +} from "shared/utils/agent-settings"; +import { buildLaunchSpec } from "./buildLaunchSpec"; +import { launchContextMultiSource } from "./__fixtures__"; +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 { + 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", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "hello" }], + }, + ], + }), + getConfig("claude"), + ); + expect(spec?.agentId).toBe("claude"); + expect(spec?.taskSlug).toBe("refactor-auth"); + }); + + test("claude XML template wraps the user prompt in ", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + ], + }), + getConfig("claude"), + ); + expect(spec?.user).toHaveLength(1); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain(""); + expect(userText).toContain("refactor the auth middleware"); + expect(userText).toContain(""); + }); + + test("codex markdown template emits plain user prompt", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toBe("refactor the auth middleware"); + expect(userText).not.toContain(""); + }); + + test("empty system template produces empty system content array", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + scope: "user", + 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", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "refactor" }], + }, + { + id: "issue:123", + kind: "github-issue", + scope: "user", + 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", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "plan" }], + }, + { + id: "task:T-1", + kind: "internal-task", + scope: "user", + label: "Task T-1", + content: [{ type: "text", text: "# T-1\n\nOne." }], + }, + { + id: "task:T-2", + kind: "internal-task", + scope: "user", + 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", + scope: "user", + label: "Prompt", + content: [{ type: "text", text: "fix the bug" }], + }, + { + id: "attachment:logs.txt", + kind: "attachment", + scope: "user", + label: "logs.txt", + content: [ + { + type: "file", + data: TXT_ATTACHMENT.data, + mediaType: TXT_ATTACHMENT.mediaType, + filename: TXT_ATTACHMENT.filename, + }, + ], + }, + { + id: "attachment:screen.png", + kind: "attachment", + scope: "user", + 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 go to attachments (phase 1)", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + scope: "user", + label: "Prompt", + content: [ + { type: "text", text: "see this:" }, + { type: "image", data: PNG_BYTES, mediaType: "image/png" }, + { type: "text", text: "and fix" }, + ], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain("see this:"); + expect(userText).toContain("and fix"); + // Image from user prompt lands in attachments so chat agents can access + // it structurally; terminal executeAgentLaunch will write it to disk. + expect(spec?.attachments).toEqual([ + { type: "image", data: PNG_BYTES, mediaType: "image/png" }, + ]); + }); + + test("empty userPrompt still renders system = [] and drops empty user template cleanly", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "issue:1", + kind: "github-issue", + scope: "user", + 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. + +- .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. + +- .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", + scope: "user", + 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..17427414c41 --- /dev/null +++ b/apps/desktop/src/shared/context/buildLaunchSpec.ts @@ -0,0 +1,112 @@ +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"; + +/** + * 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). + * - Renders per-kind markdown sub-blocks into the template variables + * (userPrompt, tasks, issues, prs, attachments). + * - Fills the agent's system + user templates to produce ContentPart[]. + * - Collects non-text parts (from user-prompt inline drops + explicit + * attachment sections) into `attachments` — keeps them structured for + * chat agents; terminal adapters flatten later in executeAgentLaunch. + */ +export function buildLaunchSpec( + ctx: LaunchContext, + agentConfig: ResolvedAgentConfig | undefined, +): AgentLaunchSpec | null { + if (ctx.agent.id === "none" || !agentConfig) return null; + + const variables = buildTemplateVariables(ctx.sections); + + const systemText = renderPromptTemplate( + agentConfig.contextPromptTemplateSystem, + variables, + ); + const userText = renderPromptTemplate( + agentConfig.contextPromptTemplateUser, + variables, + ); + + const system: ContentPart[] = systemText + ? [{ type: "text", text: systemText }] + : []; + const user: ContentPart[] = userText + ? [{ type: "text", text: userText }] + : []; + + return { + agentId: ctx.agent.id, + system, + user, + attachments: collectAttachments(ctx.sections), + taskSlug: ctx.taskSlug, + }; +} + +function buildTemplateVariables( + sections: ContextSection[], +): Record { + return { + userPrompt: renderUserPromptText(sectionsOfKind(sections, "user-prompt")), + tasks: renderKindBlock(sectionsOfKind(sections, "internal-task")), + issues: renderKindBlock(sectionsOfKind(sections, "github-issue")), + prs: renderKindBlock(sectionsOfKind(sections, "github-pr")), + attachments: renderAttachmentsList(sectionsOfKind(sections, "attachment")), + }; +} + +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 => p.type === "text") + .map((p) => p.text); +} + +function renderUserPromptText(sections: ContextSection[]): string { + return sections + .flatMap(textPartsOf) + .join("\n\n") + .trim(); +} + +function renderKindBlock(sections: ContextSection[]): string { + if (sections.length === 0) return ""; + return sections + .map((s) => textPartsOf(s).join("\n\n")) + .filter(Boolean) + .join("\n\n"); +} + +function renderAttachmentsList(sections: ContextSection[]): string { + if (sections.length === 0) return ""; + return sections.map((s) => `- .superset/attachments/${s.label}`).join("\n"); +} + +function collectAttachments(sections: ContextSection[]): ContentPart[] { + const parts: ContentPart[] = []; + for (const section of sections) { + // explicit attachments (file/image parts) + inline non-text parts + // anywhere else (e.g. rich-editor user prompt with inline image) + for (const part of section.content) { + if (part.type !== "text") parts.push(part); + } + } + return parts; +} From 71bae11599fb09213b2fddffd30e941128e70270 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 10:29:28 -0700 Subject: [PATCH 12/42] Lint --- apps/desktop/src/shared/context/buildLaunchSpec.test.ts | 9 +++------ apps/desktop/src/shared/context/buildLaunchSpec.ts | 9 ++++----- .../src/shared/context/composer.integration.test.ts | 6 +++--- apps/desktop/src/shared/context/composer.test.ts | 4 +++- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts index 4e8098fa33c..e03522da85c 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test"; import { indexResolvedAgentConfigs, - resolveAgentConfigs, type ResolvedAgentConfig, + resolveAgentConfigs, } from "shared/utils/agent-settings"; -import { buildLaunchSpec } from "./buildLaunchSpec"; import { launchContextMultiSource } from "./__fixtures__"; +import { buildLaunchSpec } from "./buildLaunchSpec"; import type { AttachmentFile, LaunchContext } from "./types"; function getConfig(id: string): ResolvedAgentConfig { @@ -35,10 +35,7 @@ const TXT_ATTACHMENT: AttachmentFile = { describe("buildLaunchSpec", () => { test("returns null when agent.id is 'none'", () => { - const spec = buildLaunchSpec( - baseCtx({ agent: { id: "none" } }), - undefined, - ); + const spec = buildLaunchSpec(baseCtx({ agent: { id: "none" } }), undefined); expect(spec).toBeNull(); }); diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.ts b/apps/desktop/src/shared/context/buildLaunchSpec.ts index 17427414c41..52f4677bea0 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.ts @@ -75,15 +75,14 @@ function sectionsOfKind( function textPartsOf(section: ContextSection): string[] { return section.content - .filter((p): p is Extract => p.type === "text") + .filter( + (p): p is Extract => p.type === "text", + ) .map((p) => p.text); } function renderUserPromptText(sections: ContextSection[]): string { - return sections - .flatMap(textPartsOf) - .join("\n\n") - .trim(); + return sections.flatMap(textPartsOf).join("\n\n").trim(); } function renderKindBlock(sections: ContextSection[]): string { diff --git a/apps/desktop/src/shared/context/composer.integration.test.ts b/apps/desktop/src/shared/context/composer.integration.test.ts index f9c056d0087..9cc5bfd34e4 100644 --- a/apps/desktop/src/shared/context/composer.integration.test.ts +++ b/apps/desktop/src/shared/context/composer.integration.test.ts @@ -35,9 +35,9 @@ describe("composer + default registry (integration)", () => { projectId: "project-1", sources: [ { - kind: "user-prompt", - content: [{ type: "text", text: "refactor the auth middleware" }], - }, + 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 }, diff --git a/apps/desktop/src/shared/context/composer.test.ts b/apps/desktop/src/shared/context/composer.test.ts index d0242b20a11..b2c7b965f66 100644 --- a/apps/desktop/src/shared/context/composer.test.ts +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -221,7 +221,9 @@ describe("buildLaunchContext", () => { const ctx = await buildLaunchContext( { projectId: "p", - sources: [{ kind: "user-prompt", content: [{ type: "text", text: "hi" }] }], + sources: [ + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + ], agent: { id: "none" }, }, { contributors: registry({}), resolveCtx }, From 47e326e8bcafc88e5ab7ac1eef71b1cab993c426 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 10:44:24 -0700 Subject: [PATCH 13/42] =?UTF-8?q?refactor(agents):=20drop=20Claude=20XML?= =?UTF-8?q?=20default=20template=20=E2=80=94=20markdown=20is=20enough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1 never wrapped prompts in XML; V1 has shipped with bare markdown/text forever. Shipping an XML-only Claude default was speculative and added a per-agent divergence without evidence. - Remove DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM / _USER. - Claude terminal + superset-chat ship the default markdown templates (same as codex/cursor/custom). - Users can still override per-agent in settings if XML wrapping helps their use case — defaults stay neutral. Also ships scripts/demo-launch-spec.ts for local template iteration: run `bun run scripts/demo-launch-spec.ts [agent...]` to eyeball what buildLaunchSpec produces for canonical inputs. 66 tests green across context + agent-settings; 14 green in shared. --- apps/desktop/scripts/demo-launch-spec.ts | 197 ++++++++++++++++++ .../shared/context/buildLaunchSpec.test.ts | 61 ++---- .../src/shared/utils/agent-settings.test.ts | 42 +--- packages/shared/src/agent-catalog.ts | 8 +- .../shared/src/agent-prompt-template.test.ts | 32 +-- packages/shared/src/agent-prompt-template.ts | 24 +-- .../shared/src/builtin-terminal-agents.ts | 8 +- 7 files changed, 239 insertions(+), 133 deletions(-) create mode 100644 apps/desktop/scripts/demo-launch-spec.ts diff --git a/apps/desktop/scripts/demo-launch-spec.ts b/apps/desktop/scripts/demo-launch-spec.ts new file mode 100644 index 00000000000..db0c6ab0db1 --- /dev/null +++ b/apps/desktop/scripts/demo-launch-spec.ts @@ -0,0 +1,197 @@ +/** + * 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: "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)); + } + } + } +} + +console.log("\n"); diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts index e03522da85c..00d35d2e2f3 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -64,46 +64,31 @@ describe("buildLaunchSpec", () => { expect(spec?.taskSlug).toBe("refactor-auth"); }); - test("claude XML template wraps the user prompt in ", () => { - const spec = buildLaunchSpec( - baseCtx({ - sections: [ - { - id: "user-prompt", - kind: "user-prompt", - scope: "user", - label: "Prompt", - content: [{ type: "text", text: "refactor the auth middleware" }], - }, - ], - }), + test("all builtin agents share the default markdown template (no XML)", () => { + const section = { + id: "user-prompt", + kind: "user-prompt" as const, + scope: "user" as const, + label: "Prompt", + content: [ + { type: "text" as const, text: "refactor the auth middleware" }, + ], + }; + const claudeSpec = buildLaunchSpec( + baseCtx({ sections: [section] }), getConfig("claude"), ); - expect(spec?.user).toHaveLength(1); - const userText = (spec?.user[0] as { type: "text"; text: string }).text; - expect(userText).toContain(""); - expect(userText).toContain("refactor the auth middleware"); - expect(userText).toContain(""); - }); - - test("codex markdown template emits plain user prompt", () => { - const spec = buildLaunchSpec( - baseCtx({ - sections: [ - { - id: "user-prompt", - kind: "user-prompt", - scope: "user", - label: "Prompt", - content: [{ type: "text", text: "refactor the auth middleware" }], - }, - ], - }), + const codexSpec = buildLaunchSpec( + baseCtx({ sections: [section], agent: { id: "codex" } }), getConfig("codex"), ); - const userText = (spec?.user[0] as { type: "text"; text: string }).text; - expect(userText).toBe("refactor the auth middleware"); - expect(userText).not.toContain(""); + 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(""); }); test("empty system template produces empty system content array", () => { @@ -313,9 +298,7 @@ describe("buildLaunchSpec", () => { "system": [], "taskSlug": "refactor-auth", "userText": -" -refactor the auth middleware - +"refactor the auth middleware # Refactor auth middleware diff --git a/apps/desktop/src/shared/utils/agent-settings.test.ts b/apps/desktop/src/shared/utils/agent-settings.test.ts index 60d1be18345..b0b2762de2a 100644 --- a/apps/desktop/src/shared/utils/agent-settings.test.ts +++ b/apps/desktop/src/shared/utils/agent-settings.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test"; import { getBuiltinAgentDefinition } from "@superset/shared/agent-catalog"; import { - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, } from "@superset/shared/agent-prompt-template"; @@ -276,34 +274,16 @@ describe("custom agent definition helpers", () => { }); describe("contextPromptTemplate resolution", () => { - test("claude terminal ships the Claude XML defaults", () => { - const claude = resolveAgentConfigs({}).find((p) => p.id === "claude"); - expect(claude?.contextPromptTemplateSystem).toBe( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - ); - expect(claude?.contextPromptTemplateUser).toBe( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, - ); - }); - - test("codex terminal ships the markdown defaults", () => { - const codex = resolveAgentConfigs({}).find((p) => p.id === "codex"); - expect(codex?.contextPromptTemplateSystem).toBe( - DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - ); - expect(codex?.contextPromptTemplateUser).toBe( - DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, - ); - }); - - test("superset-chat ships the Claude XML defaults", () => { - const chat = resolveAgentConfigs({}).find((p) => p.id === "superset-chat"); - expect(chat?.contextPromptTemplateSystem).toBe( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - ); - expect(chat?.contextPromptTemplateUser).toBe( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, - ); + 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", () => { @@ -323,7 +303,7 @@ describe("contextPromptTemplate resolution", () => { "custom user template {{userPrompt}}", ); expect(claude?.contextPromptTemplateSystem).toBe( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, ); expect(claude?.overriddenFields).toContain("contextPromptTemplateUser"); }); diff --git a/packages/shared/src/agent-catalog.ts b/packages/shared/src/agent-catalog.ts index 93db8c233c4..dba367e6330 100644 --- a/packages/shared/src/agent-catalog.ts +++ b/packages/shared/src/agent-catalog.ts @@ -7,8 +7,8 @@ import type { } from "./agent-definition"; import { DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, } from "./agent-prompt-template"; import { BUILTIN_TERMINAL_AGENT_TYPES, @@ -47,8 +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_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - contextPromptTemplateUser: DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, + 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-prompt-template.test.ts b/packages/shared/src/agent-prompt-template.test.ts index 2badff41a48..fc612c1ca40 100644 --- a/packages/shared/src/agent-prompt-template.test.ts +++ b/packages/shared/src/agent-prompt-template.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test"; import { AGENT_CONTEXT_PROMPT_VARIABLES, - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, getSupportedContextPromptVariables, @@ -124,19 +122,7 @@ describe("default context templates", () => { ).toBe(true); }); - test("claude defaults only reference known variables", () => { - expect( - validateContextPromptTemplate(DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER) - .valid, - ).toBe(true); - expect( - validateContextPromptTemplate( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - ).valid, - ).toBe(true); - }); - - test("rendering the markdown user template collapses empty sections cleanly", () => { + test("rendering the user template collapses empty sections cleanly", () => { const rendered = renderPromptTemplate( DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, { @@ -149,20 +135,4 @@ describe("default context templates", () => { ); expect(rendered).toBe("refactor auth"); }); - - test("rendering the claude user template wraps user-request in XML", () => { - const rendered = renderPromptTemplate( - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, - { - userPrompt: "refactor auth", - tasks: "", - issues: "", - prs: "", - attachments: "", - }, - ); - expect(rendered).toContain(""); - expect(rendered).toContain("refactor auth"); - expect(rendered).toContain(""); - }); }); diff --git a/packages/shared/src/agent-prompt-template.ts b/packages/shared/src/agent-prompt-template.ts index a4b4e54e8a9..643ba906b61 100644 --- a/packages/shared/src/agent-prompt-template.ts +++ b/packages/shared/src/agent-prompt-template.ts @@ -125,8 +125,9 @@ export function validateContextPromptTemplate(template: string): { } /** - * Default context templates for non-Claude agents (codex, cursor, user - * custom). Markdown with the pre-rendered kind-blocks dropped in order. + * 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. @@ -143,25 +144,6 @@ export const DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER = `{{userPrompt}} {{attachments}}`; -/** - * Default context templates for Claude agents. The user-request is - * wrapped in XML to stabilize Claude's parsing. Per-kind blocks stay as - * pre-rendered markdown (users can tighten further in settings). - */ -export const DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM = ""; - -export const DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER = ` -{{userPrompt}} - - -{{tasks}} - -{{issues}} - -{{prs}} - -{{attachments}}`; - // --------------------------------------------------------------------------- // Shared validator // --------------------------------------------------------------------------- diff --git a/packages/shared/src/builtin-terminal-agents.ts b/packages/shared/src/builtin-terminal-agents.ts index f13fc4895d4..0f3932df330 100644 --- a/packages/shared/src/builtin-terminal-agents.ts +++ b/packages/shared/src/builtin-terminal-agents.ts @@ -4,11 +4,7 @@ import { type TerminalAgentDefinitionInput, } from "./agent-definition"; import type { PromptTransport } from "./agent-prompt-launch"; -import { - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, - DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, -} from "./agent-prompt-template"; +import { DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE } from "./agent-prompt-template"; interface BuiltinTerminalAgentManifest extends Omit< @@ -68,8 +64,6 @@ export const BUILTIN_TERMINAL_AGENTS = [ "Anthropic's coding agent for reading code, editing files, and running terminal workflows.", command: "claude --dangerously-skip-permissions", includeInDefaultTerminalPresets: true, - contextPromptTemplateSystem: DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_SYSTEM, - contextPromptTemplateUser: DEFAULT_CLAUDE_CONTEXT_PROMPT_TEMPLATE_USER, }), createBuiltinTerminalAgent({ id: "amp", From 3359053b17473bc4c56db007a0a6c4d4d9fa2000 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 10:56:27 -0700 Subject: [PATCH 14/42] feat(desktop/context): preserve inline order for rich-editor user prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildLaunchSpec used to flatten the user-prompt section's ContentPart[] to a single text blob. That lost inline ordering when a rich editor produces text + image + text — the image landed in spec.attachments disconnected from its position. Now: - Split the agent's user template on {{userPrompt}}; render each half's other placeholders raw (no trim / no newline collapse) so whitespace around the placeholder is preserved. - Splice the user-prompt section's ContentPart[] in at the split position, keeping [text, image, text] ordering intact. - Merge adjacent text parts, then collapse 3+ newlines to 2 and trim document boundaries in a final pass. - spec.attachments now carries only *explicit* attachment-kind sections; inline non-text parts from user-prompt stay inline in spec.user. - Inline parts still appear in the {{attachments}} list so CLI agents reading just the flattened text get a file-path reference. Chat agents: hand spec.user straight to the Anthropic/AI SDK user message content[] — model sees the image between the text chunks. Terminal adapters (step 7) will flatten file/image parts to `![filename](.superset/attachments/...)` markdown refs at their inline position, then write files to disk. Demo script gets two new scenarios exercising the rich-editor flow: text+image+text alone, and the same with a linked issue. 67 tests green across context + agent-settings. --- apps/desktop/scripts/demo-launch-spec.ts | 47 ++++- .../shared/context/buildLaunchSpec.test.ts | 59 +++++- .../src/shared/context/buildLaunchSpec.ts | 179 ++++++++++++++---- 3 files changed, 241 insertions(+), 44 deletions(-) diff --git a/apps/desktop/scripts/demo-launch-spec.ts b/apps/desktop/scripts/demo-launch-spec.ts index db0c6ab0db1..3ac9aa87ff2 100644 --- a/apps/desktop/scripts/demo-launch-spec.ts +++ b/apps/desktop/scripts/demo-launch-spec.ts @@ -83,6 +83,41 @@ const SCENARIOS: Scenario[] = [ }, ], }, + { + 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: [ @@ -188,7 +223,17 @@ for (const scenario of SCENARIOS) { if (spec.user.length > 0) { console.log("\n[USER]"); for (const part of spec.user) { - if (part.type === "text") console.log(indent(part.text)); + if (part.type === "text") { + console.log(indent(part.text)); + } else if (part.type === "image") { + console.log(indent(``)); + } else if (part.type === "file") { + console.log( + indent( + ``, + ), + ); + } } } } diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts index 00d35d2e2f3..8dc4a97a17e 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -223,7 +223,7 @@ describe("buildLaunchSpec", () => { expect(spec?.attachments[1]?.type).toBe("image"); }); - test("inline non-text parts from user-prompt go to attachments (phase 1)", () => { + test("inline non-text parts from user-prompt stay inline in spec.user", () => { const spec = buildLaunchSpec( baseCtx({ sections: [ @@ -242,14 +242,55 @@ describe("buildLaunchSpec", () => { }), getConfig("codex"), ); - const userText = (spec?.user[0] as { type: "text"; text: string }).text; - expect(userText).toContain("see this:"); - expect(userText).toContain("and fix"); - // Image from user prompt lands in attachments so chat agents can access - // it structurally; terminal executeAgentLaunch will write it to disk. - expect(spec?.attachments).toEqual([ - { type: "image", data: PNG_BYTES, mediaType: "image/png" }, - ]); + + // 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", + scope: "user", + 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", () => { diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.ts b/apps/desktop/src/shared/context/buildLaunchSpec.ts index 52f4677bea0..a44583cf8fb 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.ts @@ -8,18 +8,25 @@ import type { LaunchSourceKind, } from "./types"; +const USER_PROMPT_PLACEHOLDER = "{{userPrompt}}"; +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). - * - Renders per-kind markdown sub-blocks into the template variables - * (userPrompt, tasks, issues, prs, attachments). - * - Fills the agent's system + user templates to produce ContentPart[]. - * - Collects non-text parts (from user-prompt inline drops + explicit - * attachment sections) into `attachments` — keeps them structured for - * chat agents; terminal adapters flatten later in executeAgentLaunch. + * - 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, @@ -27,42 +34,121 @@ export function buildLaunchSpec( ): AgentLaunchSpec | null { if (ctx.agent.id === "none" || !agentConfig) return null; - const variables = buildTemplateVariables(ctx.sections); + const nonUserVariables = buildNonUserPromptVariables(ctx.sections); + const userPromptParts = collectUserPromptContent(ctx.sections); - const systemText = renderPromptTemplate( + const system = renderScalarTemplate( agentConfig.contextPromptTemplateSystem, - variables, + nonUserVariables, ); - const userText = renderPromptTemplate( + const user = renderUserTemplate( agentConfig.contextPromptTemplateUser, - variables, + userPromptParts, + nonUserVariables, ); - const system: ContentPart[] = systemText - ? [{ type: "text", text: systemText }] - : []; - const user: ContentPart[] = userText - ? [{ type: "text", text: userText }] - : []; - return { agentId: ctx.agent.id, system, user, - attachments: collectAttachments(ctx.sections), + attachments: collectExplicitAttachments(ctx.sections), taskSlug: ctx.taskSlug, }; } -function buildTemplateVariables( +function renderScalarTemplate( + template: string, + variables: Record, +): 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, +): ContentPart[] { + const splitIndex = template.indexOf(USER_PROMPT_PLACEHOLDER); + const [beforeRaw, afterRaw] = + splitIndex === -1 + ? ["", template] + : [ + template.slice(0, splitIndex), + template.slice(splitIndex + USER_PROMPT_PLACEHOLDER.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 { + return template.replace(PLACEHOLDER_RE, (match, rawKey: string) => { + const key = rawKey.trim(); + return 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 { return { - userPrompt: renderUserPromptText(sectionsOfKind(sections, "user-prompt")), tasks: renderKindBlock(sectionsOfKind(sections, "internal-task")), issues: renderKindBlock(sectionsOfKind(sections, "github-issue")), prs: renderKindBlock(sectionsOfKind(sections, "github-pr")), - attachments: renderAttachmentsList(sectionsOfKind(sections, "attachment")), + attachments: renderAttachmentsList(sections), }; } @@ -81,8 +167,8 @@ function textPartsOf(section: ContextSection): string[] { .map((p) => p.text); } -function renderUserPromptText(sections: ContextSection[]): string { - return sections.flatMap(textPartsOf).join("\n\n").trim(); +function collectUserPromptContent(sections: ContextSection[]): ContentPart[] { + return sectionsOfKind(sections, "user-prompt").flatMap((s) => s.content); } function renderKindBlock(sections: ContextSection[]): string { @@ -93,19 +179,44 @@ function renderKindBlock(sections: ContextSection[]): string { .join("\n\n"); } +/** + * Attachments list 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. + */ function renderAttachmentsList(sections: ContextSection[]): string { - if (sections.length === 0) return ""; - return sections.map((s) => `- .superset/attachments/${s.label}`).join("\n"); + 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"}`); + } + } + return refs.length === 0 ? "" : refs.join("\n"); } -function collectAttachments(sections: ContextSection[]): ContentPart[] { - const parts: ContentPart[] = []; - for (const section of sections) { - // explicit attachments (file/image parts) + inline non-text parts - // anywhere else (e.g. rich-editor user prompt with inline image) - for (const part of section.content) { - if (part.type !== "text") parts.push(part); +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 parts; + return merged; } From 938ea7f63fd7f526d19e1df6761ce612b552be58 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 11:04:31 -0700 Subject: [PATCH 15/42] =?UTF-8?q?feat(desktop/context):=20add=20buildAgent?= =?UTF-8?q?LaunchRequest=20=E2=80=94=20V2=20spec=20to=20V1=20launch=20brid?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 7 (scoped): hand V2's AgentLaunchSpec off to V1's battle-tested terminal-adapter / chat-adapter without building new execution infrastructure. Bytes-over-IPC / SuperJSON transformer work is deferred to a follow-up. buildAgentLaunchRequest(spec, agentConfig, { workspaceId, source }): - Returns null for agentId "none" or disabled agents (V1 parity). - Assigns collision-safe filenames across all binary parts (inline in spec.user + explicit spec.attachments). Uses the same sanitize + dedup algorithm V1 already uses so nothing drifts. - Flattens spec.user to markdown text with file/image parts rendered as `![filename](.superset/attachments/filename)` at their inline positions — rich-editor ordering survives into the CLI prompt. - Converts Uint8Array binary data to base64 data URLs at this boundary (V1 wire format). Internal plumbing stays on Uint8Array. - Chat: initialPrompt = flattened text, taskSlug/model flow through. - Terminal: command = buildPromptCommandFromAgentConfig(flattened text), or the non-prompt command when the prompt is empty. 10 new tests cover: "none" short-circuit, terminal command rendering, chat initialPrompt/taskSlug, inline image path-ref correctness, explicit attachment filename preservation, filename dedup across user + attachments, base64 data-URL format, workspaceId/source passthrough. 77 tests green across context + agent-settings. --- .../context/buildAgentLaunchRequest.test.ts | 211 ++++++++++++++++++ .../shared/context/buildAgentLaunchRequest.ts | 210 +++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts create mode 100644 apps/desktop/src/shared/context/buildAgentLaunchRequest.ts diff --git a/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts b/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts new file mode 100644 index 00000000000..3f8c422f785 --- /dev/null +++ b/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { + indexResolvedAgentConfigs, + resolveAgentConfigs, + type ResolvedAgentConfig, +} from "shared/utils/agent-settings"; +import { buildAgentLaunchRequest } from "./buildAgentLaunchRequest"; +import type { AgentLaunchSpec } 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; +} + +const PNG_BYTES = new Uint8Array([137, 80, 78, 71]); + +function baseSpec(overrides: Partial = {}): AgentLaunchSpec { + return { + agentId: "claude", + system: [], + user: [{ type: "text", text: "hello" }], + attachments: [], + taskSlug: undefined, + ...overrides, + }; +} + +describe("buildAgentLaunchRequest", () => { + test("returns null for agentId 'none'", () => { + const req = buildAgentLaunchRequest( + baseSpec({ agentId: "none" as never }), + getConfig("claude"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + expect(req).toBeNull(); + }); + + test("terminal: flattens user text into buildPromptCommand", () => { + const req = buildAgentLaunchRequest( + baseSpec({ + agentId: "codex", + user: [ + { type: "text", text: "refactor the auth middleware" }, + ], + }), + getConfig("codex"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + expect(req?.kind).toBe("terminal"); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + expect(req.workspaceId).toBe("ws-1"); + expect(req.agentType).toBe("codex"); + expect(req.source).toBe("new-workspace"); + // Command is rendered via buildPromptCommandFromAgentConfig — contains codex CLI base + expect(req.terminal.command).toContain("codex"); + expect(req.terminal.name).toBe("Codex"); + }); + + test("chat: user text becomes initialPrompt + taskSlug flows through", () => { + const req = buildAgentLaunchRequest( + baseSpec({ + agentId: "superset-chat", + user: [{ type: "text", text: "refactor" }], + taskSlug: "refactor-auth", + }), + getConfig("superset-chat"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + expect(req?.kind).toBe("chat"); + if (req?.kind !== "chat") throw new Error("wrong kind"); + expect(req.chat.initialPrompt).toBe("refactor"); + expect(req.chat.taskSlug).toBe("refactor-auth"); + }); + + test("terminal: inline image in user flattens to path ref with assigned filename", () => { + const req = buildAgentLaunchRequest( + baseSpec({ + agentId: "codex", + user: [ + { type: "text", text: "see this:" }, + { type: "image", data: PNG_BYTES, mediaType: "image/png" }, + { type: "text", text: "fix it" }, + ], + }), + getConfig("codex"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + // Command should contain the inline path ref + expect(req.terminal.command).toContain("attachment_1"); + // Same filename in initialFiles[] + expect(req.terminal.initialFiles).toHaveLength(1); + expect(req.terminal.initialFiles?.[0]?.filename).toBe("attachment_1"); + }); + + test("terminal: explicit attachment keeps its original filename", () => { + const req = buildAgentLaunchRequest( + baseSpec({ + agentId: "codex", + attachments: [ + { + type: "file", + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + }), + getConfig("codex"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + expect(req.terminal.initialFiles?.[0]?.filename).toBe("logs.txt"); + }); + + test("terminal: dedupes colliding filenames across user + attachments", () => { + const req = buildAgentLaunchRequest( + baseSpec({ + agentId: "codex", + user: [ + { + type: "file", + data: new Uint8Array([1]), + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + attachments: [ + { + type: "file", + data: new Uint8Array([2]), + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + }), + getConfig("codex"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + const filenames = req.terminal.initialFiles?.map((f) => f.filename); + expect(new Set(filenames).size).toBe(filenames?.length ?? 0); + }); + + test("chat: attachments are converted to base64 data URLs", () => { + const req = buildAgentLaunchRequest( + baseSpec({ + agentId: "superset-chat", + attachments: [ + { + type: "file", + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + }), + getConfig("superset-chat"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + if (req?.kind !== "chat") throw new Error("wrong kind"); + const file = req.chat.initialFiles?.[0]; + expect(file?.data).toMatch(/^data:text\/plain;base64,/); + // base64 of [1,2,3] + expect(file?.data).toBe("data:text/plain;base64,AQID"); + }); + + test("chat: initialPrompt includes inline file/image refs", () => { + const req = buildAgentLaunchRequest( + baseSpec({ + agentId: "superset-chat", + user: [ + { type: "text", text: "look at" }, + { + type: "file", + data: new Uint8Array([1]), + mediaType: "text/plain", + filename: "trace.log", + }, + ], + }), + getConfig("superset-chat"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + if (req?.kind !== "chat") throw new Error("wrong kind"); + expect(req.chat.initialPrompt).toContain("trace.log"); + }); + + test("empty user content + empty attachments → still produces a valid launch (uses command without prompt)", () => { + const req = buildAgentLaunchRequest( + baseSpec({ agentId: "codex", user: [] }), + getConfig("codex"), + { workspaceId: "ws-1", source: "new-workspace" }, + ); + expect(req?.kind).toBe("terminal"); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + expect(req.terminal.command).toBeTruthy(); + }); + + test("uses passed workspaceId + source verbatim", () => { + const req = buildAgentLaunchRequest( + baseSpec({ agentId: "codex" }), + getConfig("codex"), + { workspaceId: "some-workspace-42", source: "mcp" }, + ); + expect(req?.workspaceId).toBe("some-workspace-42"); + expect(req?.source).toBe("mcp"); + }); +}); diff --git a/apps/desktop/src/shared/context/buildAgentLaunchRequest.ts b/apps/desktop/src/shared/context/buildAgentLaunchRequest.ts new file mode 100644 index 00000000000..0db355cebb4 --- /dev/null +++ b/apps/desktop/src/shared/context/buildAgentLaunchRequest.ts @@ -0,0 +1,210 @@ +import type { + AgentLaunchRequest, + AgentLaunchSource, +} from "@superset/shared/agent-launch"; +import { + buildPromptCommandFromAgentConfig, + getCommandFromAgentConfig, + type ResolvedAgentConfig, + type TerminalResolvedAgentConfig, +} from "shared/utils/agent-settings"; +import type { AgentLaunchSpec, ContentPart } from "./types"; + +interface BuildOpts { + workspaceId: string; + source: AgentLaunchSource; +} + +/** + * Bridge V2 AgentLaunchSpec into the V1 AgentLaunchRequest shape so the + * existing terminal-adapter / chat-adapter infrastructure can consume + * it verbatim. No new IPC wiring needed. + * + * Responsibilities: + * - Assign collision-safe filenames across all binary parts (inline in + * user + explicit attachments) so the prompt text's path refs match + * what the adapter writes to disk. + * - Flatten spec.user to markdown text, with file/image parts rendered + * as `![filename](.superset/attachments/filename)` at their inline + * position — preserves editor order for CLI agents. + * - Convert Uint8Array binary data to base64 data URLs (V1 wire format). + * - Chat: initialPrompt = flattened text. + * - Terminal: command = buildPromptCommandFromAgentConfig(flattened text). + * + * Base64 encoding happens at this boundary only — internal plumbing + * stays on Uint8Array. + */ +export function buildAgentLaunchRequest( + spec: AgentLaunchSpec, + agentConfig: ResolvedAgentConfig, + opts: BuildOpts, +): AgentLaunchRequest | null { + if (spec.agentId === "none" || !agentConfig.enabled) return null; + + const assigned = assignFilenames(spec); + const initialFiles = assigned.length > 0 ? assigned.map(toV1File) : undefined; + const promptText = flattenUserContent(spec.user, assigned); + + if (agentConfig.kind === "chat") { + return { + kind: "chat", + workspaceId: opts.workspaceId, + agentType: agentConfig.id, + source: opts.source, + chat: { + initialPrompt: promptText || undefined, + initialFiles, + model: agentConfig.model, + taskSlug: spec.taskSlug, + }, + }; + } + + const command = buildTerminalCommand(agentConfig, promptText); + if (!command) return null; + return { + kind: "terminal", + workspaceId: opts.workspaceId, + agentType: agentConfig.id, + source: opts.source, + terminal: { + command, + name: agentConfig.label, + initialFiles, + }, + }; +} + +function buildTerminalCommand( + config: TerminalResolvedAgentConfig, + prompt: string, +): string | null { + if (!prompt.trim()) return getCommandFromAgentConfig(config); + return buildPromptCommandFromAgentConfig({ + prompt, + randomId: crypto.randomUUID(), + config, + }); +} + +// ------------------------------------------------------------------------- +// Filename assignment (collision-safe across inline + explicit attachments) +// ------------------------------------------------------------------------- + +type BinaryPart = Exclude; + +interface AssignedBinary { + part: BinaryPart; + filename: string; + /** True if this binary appeared inline within spec.user */ + inline: boolean; + /** Index within its owning array — used to identify user-inline binaries when flattening */ + inlineIndex?: number; +} + +function assignFilenames(spec: AgentLaunchSpec): AssignedBinary[] { + const used = new Set(); + const out: AssignedBinary[] = []; + + spec.user.forEach((part, index) => { + if (part.type === "text") return; + out.push({ + part, + filename: nextName(part, used, out.length), + inline: true, + inlineIndex: index, + }); + }); + + for (const part of spec.attachments) { + if (part.type === "text") continue; + out.push({ + part, + filename: nextName(part, used, out.length), + inline: false, + }); + } + + return out; +} + +function nextName( + part: BinaryPart, + used: Set, + fallbackIndex: number, +): string { + const raw = part.type === "file" ? part.filename : undefined; + const sanitized = raw ? sanitize(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 sanitize(filename: string): string { + const cleaned = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + return cleaned.trim() ? cleaned : ""; +} + +// ------------------------------------------------------------------------- +// Flatten user content to markdown text with inline file/image refs +// ------------------------------------------------------------------------- + +function flattenUserContent( + user: ContentPart[], + assigned: AssignedBinary[], +): string { + const inlineByIndex = new Map(); + for (const a of assigned) { + if (a.inline && a.inlineIndex !== undefined) { + inlineByIndex.set(a.inlineIndex, a.filename); + } + } + + 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(); +} + +// ------------------------------------------------------------------------- +// Base64 conversion at the V1 wire boundary +// ------------------------------------------------------------------------- + +function toV1File(entry: AssignedBinary): { + data: string; + mediaType: string; + filename?: string; +} { + const { part, filename } = entry; + const base64 = Buffer.from(part.data).toString("base64"); + return { + data: `data:${part.mediaType};base64,${base64}`, + mediaType: part.mediaType, + filename, + }; +} From 81feb225d6e9a12270e7b99f173daf1535209fb9 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 11:17:55 -0700 Subject: [PATCH 16/42] feat(desktop): add useEnqueueAgentLaunch hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 8 of the v2 launch-context composition plan. Thin wrapper around V1's useWorkspaceInitStore.addPendingTerminalSetup for the V2 submit flow. Called after host-service.workspaceCreation resolves the real workspaceId. V1's terminal-adapter / chat-adapter pick up the pending setup when the workspace mounts and execute the launch — no new adapter code needed. - buildPendingSetup(args) — pure function, rewrites launchRequest workspaceId to the real id and assembles the PendingTerminalSetup. - useEnqueueAgentLaunch() — React hook wrapper that calls into the zustand action. - Null launchRequest is a no-op (nothing to enqueue, e.g. agent "none"). Tests cover: null short-circuit, workspaceId rewrite, projectId passthrough, initialCommands shape, non-workspaceId field preservation. 5 tests green; typecheck clean. --- .../hooks/useEnqueueAgentLaunch/index.ts | 1 + .../useEnqueueAgentLaunch.test.ts | 70 +++++++++++++++++++ .../useEnqueueAgentLaunch.ts | 60 ++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts create mode 100644 apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts create mode 100644 apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts diff --git a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts new file mode 100644 index 00000000000..6726914f031 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts @@ -0,0 +1 @@ +export { useEnqueueAgentLaunch } from "./useEnqueueAgentLaunch"; diff --git a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts new file mode 100644 index 00000000000..6f55d260dc4 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import { buildPendingSetup } from "./useEnqueueAgentLaunch"; + +function exampleRequest( + overrides: Partial = {}, +): AgentLaunchRequest { + return { + kind: "terminal", + workspaceId: "pending-workspace", + agentType: "codex", + source: "new-workspace", + terminal: { command: "codex", name: "Codex" }, + ...overrides, + } as AgentLaunchRequest; +} + +describe("buildPendingSetup", () => { + test("returns null when launchRequest is null", () => { + expect( + buildPendingSetup({ + workspaceId: "ws-1", + projectId: "proj-1", + launchRequest: null, + }), + ).toBeNull(); + }); + + test("rewrites launchRequest.workspaceId to the real id", () => { + const setup = buildPendingSetup({ + workspaceId: "ws-real-42", + projectId: "proj-1", + launchRequest: exampleRequest({ workspaceId: "pending-workspace" }), + }); + expect(setup?.workspaceId).toBe("ws-real-42"); + expect(setup?.agentLaunchRequest?.workspaceId).toBe("ws-real-42"); + }); + + test("passes projectId through", () => { + const setup = buildPendingSetup({ + workspaceId: "ws-1", + projectId: "proj-alpha", + launchRequest: exampleRequest(), + }); + expect(setup?.projectId).toBe("proj-alpha"); + }); + + test("initialCommands is null (V2 host-service handles setup scripts)", () => { + const setup = buildPendingSetup({ + workspaceId: "ws-1", + projectId: "proj-1", + launchRequest: exampleRequest(), + }); + expect(setup?.initialCommands).toBeNull(); + }); + + test("preserves non-workspaceId launchRequest fields (kind, agentType, chat, etc.)", () => { + const setup = buildPendingSetup({ + workspaceId: "ws-1", + projectId: "proj-1", + launchRequest: exampleRequest({ + agentType: "claude", + source: "mcp", + }), + }); + expect(setup?.agentLaunchRequest?.kind).toBe("terminal"); + expect(setup?.agentLaunchRequest?.agentType).toBe("claude"); + expect(setup?.agentLaunchRequest?.source).toBe("mcp"); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts new file mode 100644 index 00000000000..0bcde47c1a4 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts @@ -0,0 +1,60 @@ +import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import { useCallback } from "react"; +import { + type PendingTerminalSetup, + useWorkspaceInitStore, +} from "renderer/stores/workspace-init"; + +export interface EnqueueAgentLaunchArgs { + workspaceId: string; + projectId: string; + launchRequest: AgentLaunchRequest | null; +} + +/** + * Shape the pending-setup entry for a V2 launch. Returns null for an + * empty launch (nothing to stash). Exported for unit testing. + */ +export function buildPendingSetup( + args: EnqueueAgentLaunchArgs, +): PendingTerminalSetup | null { + if (!args.launchRequest) return null; + return { + workspaceId: args.workspaceId, + projectId: args.projectId, + initialCommands: null, + agentLaunchRequest: { + ...args.launchRequest, + workspaceId: args.workspaceId, + }, + }; +} + +/** + * V2 hook: stash a pending agent launch for a just-created workspace. + * + * When the workspace mounts, V1's terminal-adapter / chat-adapter read + * the pending setup and execute the launch. This is the same mechanism + * V1 uses (via useCreateWorkspace.mutateAsyncWithPendingSetup); V2's + * submit flow calls this directly after host-service.workspaceCreation + * returns the real workspaceId. + * + * Takes a V1-shaped AgentLaunchRequest (produced by + * buildAgentLaunchRequest in shared/context). Rewrites the request's + * workspaceId to the real id if it was built with a placeholder. + */ +export function useEnqueueAgentLaunch(): ( + args: EnqueueAgentLaunchArgs, +) => void { + const addPendingTerminalSetup = useWorkspaceInitStore( + (s) => s.addPendingTerminalSetup, + ); + + return useCallback( + (args) => { + const setup = buildPendingSetup(args); + if (setup) addPendingTerminalSetup(setup); + }, + [addPendingTerminalSetup], + ); +} From d6ff24685365ab8963c18e932e88df389b80fd99 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 11:36:46 -0700 Subject: [PATCH 17/42] feat(desktop/v2): wire v2 workspace launch into pending page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 9 of the v2 launch-context composition plan. Closes Gaps 4, 5 in V2_WORKSPACE_MODAL_GAPS.md for the "fork" intent (plain prompt + local host). Gaps 3 (AI branch name) and 6 (create-from-PR) remain as follow-ups. What runs now: - Modal submit → pending row → pending page fires createWorkspace. - On success, buildForkAgentLaunch runs the V2 pipeline: sources <- pending row (user-prompt, linked issues/PRs/tasks, attachments) buildLaunchContext → buildLaunchSpec → buildAgentLaunchRequest - useEnqueueAgentLaunch stashes the V1-shaped AgentLaunchRequest in useWorkspaceInitStore. V1's terminal-adapter / chat-adapter pick it up when the workspace mounts and execute the launch — no new adapter code needed. New file buildForkAgentLaunch.ts is a pure helper: builds sources from a PendingWorkspaceRow, stubs ResolveCtx from the same row's metadata, runs the pipeline, returns an AgentLaunchRequest or null. Phase 1 gap: issue / PR / task bodies are not fetched over HTTP yet — host-service lacks a body endpoint. The resolver returns empty bodies, so agents see title + URL + task-slug metadata only. Full-body injection is a follow-up once host-service grows getIssueContent / getPullRequestContent / getInternalTaskContent. 13 new tests cover: empty sources → null, no-agent → null, prompt-only terminal launch via default agent, taskSlug derivation, attachment passthrough, source-kind ordering. 88 tests green across pending + context + agent-settings suites. --- apps/desktop/scripts/demo-launch-spec.ts | 16 +- .../$pendingId/buildForkAgentLaunch.test.ts | 248 ++++++++++++++++++ .../$pendingId/buildForkAgentLaunch.ts | 182 +++++++++++++ .../_dashboard/pending/$pendingId/page.tsx | 41 ++- .../context/buildAgentLaunchRequest.test.ts | 6 +- 5 files changed, 479 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts diff --git a/apps/desktop/scripts/demo-launch-spec.ts b/apps/desktop/scripts/demo-launch-spec.ts index 3ac9aa87ff2..87ddf277c50 100644 --- a/apps/desktop/scripts/demo-launch-spec.ts +++ b/apps/desktop/scripts/demo-launch-spec.ts @@ -178,7 +178,11 @@ for (const scenario of SCENARIOS) { console.log(divider("=")); const ctx = await buildLaunchContext( - { projectId: "demo-project", sources: scenario.sources, agent: { id: "claude" } }, + { + projectId: "demo-project", + sources: scenario.sources, + agent: { id: "claude" }, + }, { contributors: defaultContributorRegistry, resolveCtx }, ); @@ -208,9 +212,9 @@ for (const scenario of SCENARIOS) { 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"})`, + `attachments: ${spec.attachments.length} (${ + spec.attachments.map((p) => p.type).join(", ") || "none" + })`, ); if (spec.system.length > 0) { @@ -226,7 +230,9 @@ for (const scenario of SCENARIOS) { if (part.type === "text") { console.log(indent(part.text)); } else if (part.type === "image") { - console.log(indent(``)); + console.log( + indent(``), + ); } else if (part.type === "file") { console.log( indent( 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..db31d6aa175 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts @@ -0,0 +1,248 @@ +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[0] + > = {}, +): Parameters[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("maps github-sourced linkedIssue to github-issue", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ + linkedIssues: [ + { + source: "github", + url: "https://github.com/acme/repo/issues/1", + number: 1, + slug: "x", + title: "X", + state: "open", + }, + ], + }), + undefined, + ); + expect(sources).toEqual([ + { + kind: "github-issue", + url: "https://github.com/acme/repo/issues/1", + }, + ]); + }); + + test("maps internal-sourced linkedIssue with taskId to internal-task", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ + linkedIssues: [ + { + source: "internal", + taskId: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth", + }, + ], + }), + undefined, + ); + expect(sources).toEqual([{ kind: "internal-task", id: "TASK-42" }]); + }); + + test("adds github-pr source for linkedPR", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ + linkedPR: { + prNumber: 200, + url: "https://github.com/acme/repo/pull/200", + title: "Rewrite auth", + state: "open", + }, + }), + undefined, + ); + expect(sources).toEqual([ + { + kind: "github-pr", + url: "https://github.com/acme/repo/pull/200", + }, + ]); + }); + + test("converts base64 data URL attachments to Uint8Array sources", () => { + 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(source.file.mediaType).toBe("text/plain"); + expect(Array.from(source.file.data)).toEqual([1, 2, 3]); + }); + + test("orders sources: user-prompt, issues/tasks, PR, attachments", () => { + 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", + ]); + }); +}); + +describe("buildForkAgentLaunch", () => { + const agentConfigs = resolveAgentConfigs({}); + + test("returns null when there are no sources", async () => { + const req = await buildForkAgentLaunch({ + pending: pendingBase(), + attachments: undefined, + agentConfigs, + }); + expect(req).toBeNull(); + }); + + test("returns null when there are no enabled agents", async () => { + const req = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "hi" }), + attachments: undefined, + agentConfigs: [], + }); + expect(req).toBeNull(); + }); + + test("produces a terminal request for a prompt-only launch via default agent", async () => { + const req = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "refactor the auth middleware" }), + attachments: undefined, + agentConfigs, + }); + expect(req?.kind).toBe("terminal"); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + // getFallbackAgentId prefers "claude" when enabled + expect(req.agentType).toBe("claude"); + expect(req.source).toBe("new-workspace"); + expect(req.terminal.command).toContain("claude"); + }); + + test("linked internal task derives taskSlug in the request", async () => { + const req = await buildForkAgentLaunch({ + pending: pendingBase({ + prompt: "do it", + linkedIssues: [ + { + source: "internal", + taskId: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth", + }, + ], + }), + attachments: undefined, + agentConfigs, + }); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + // Terminal command doesn't surface taskSlug; ensure the prompt + // payload at least carries the task title. + expect(req.terminal.command).toContain("Refactor auth"); + }); + + test("attachment bytes flow through to initialFiles as base64 data URL", async () => { + const req = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "fix" }), + attachments: [ + { + data: "data:text/plain;base64,AQID", // [1,2,3] + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + agentConfigs, + }); + if (req?.kind !== "terminal") throw new Error("wrong kind"); + const file = req.terminal.initialFiles?.[0]; + expect(file?.filename).toBe("logs.txt"); + expect(file?.data).toBe("data:text/plain;base64,AQID"); + }); +}); 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..1c7b48ac76a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -0,0 +1,182 @@ +import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { buildAgentLaunchRequest } from "shared/context/buildAgentLaunchRequest"; +import { buildLaunchSpec } from "shared/context/buildLaunchSpec"; +import { buildLaunchContext } from "shared/context/composer"; +import { defaultContributorRegistry } from "shared/context/contributors"; +import type { + AttachmentFile, + LaunchSource, + ResolveCtx, +} from "shared/context/types"; +import { + 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[]; +} + +/** + * Build a V1-shaped AgentLaunchRequest for the "fork" intent once the + * host-service workspace-create succeeds. Runs the V2 composer + + * buildLaunchSpec + buildAgentLaunchRequest pipeline over whatever + * metadata the pending row has. + * + * Phase 1 note: issue / PR / task bodies are not fetched over HTTP yet + * (host-service lacks a body endpoint). The resolver returns empty + * bodies — the agent sees the title/URL/task-slug metadata only. When + * host-service grows a getIssueContent / getPullRequestContent / + * getInternalTaskContent API, swap the resolver stubs here. + */ +export async function buildForkAgentLaunch( + inputs: BuildForkAgentLaunchInputs, +): Promise { + const agentId = getFallbackAgentId(inputs.agentConfigs); + if (!agentId) return null; + + const agentConfig = indexResolvedAgentConfigs(inputs.agentConfigs).get( + agentId, + ); + if (!agentConfig) 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), + }, + ); + const spec = buildLaunchSpec(ctx, agentConfig); + if (!spec) return null; + + return buildAgentLaunchRequest(spec, agentConfig, { + workspaceId: "pending-workspace", + source: "new-workspace", + }); +} + +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: Uint8Array.from(Buffer.from(base64, "base64")), + mediaType: loaded.mediaType, + filename: loaded.filename, + }; +} + +function buildResolveCtxFromPending( + pending: BuildForkAgentLaunchInputs["pending"], +): 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, + }); + } + 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, + }); + } + 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, + }); + } + return { + id, + slug: match.slug, + title: match.title, + description: null, + }; + }, + }; +} 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 6d6f6f07767..c89817421c7 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 @@ -12,13 +12,16 @@ import { clearAttachments, loadAttachments, } from "renderer/lib/pending-attachment-store"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useAdoptWorktree } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree"; import { useCheckoutDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace"; import { useCreateDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace"; +import { useEnqueueAgentLaunch } from "renderer/hooks/useEnqueueAgentLaunch"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { buildForkAgentLaunch } from "./buildForkAgentLaunch"; import { buildAdoptPayload, buildCheckoutPayload, @@ -51,6 +54,8 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { const createWorkspace = useCreateDashboardWorkspace(); const checkoutWorkspace = useCheckoutDashboardWorkspace(); const adoptWorktree = useAdoptWorktree(); + const enqueueAgentLaunch = useEnqueueAgentLaunch(); + const agentPresetsQuery = electronTrpc.settings.getAgentPresets.useQuery(); return useCallback(async () => { if (!pending) return; @@ -66,21 +71,21 @@ 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); + loadedAttachments = await loadAttachments(pendingId); } catch { // proceed without } } result = await createWorkspace( - buildForkPayload(pendingId, pending, attachments), + buildForkPayload(pendingId, pending, loadedAttachments), ); break; } @@ -96,6 +101,30 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { } } + // Enqueue the V2 agent launch for fork intent. The workspace's + // terminal-adapter / chat-adapter picks up this pending setup when + // the workspace mounts. + if ( + pending.intent === "fork" && + result.workspace?.id && + agentPresetsQuery.data + ) { + try { + const launchRequest = await buildForkAgentLaunch({ + pending, + attachments: loadedAttachments, + agentConfigs: agentPresetsQuery.data, + }); + enqueueAgentLaunch({ + workspaceId: result.workspace.id, + projectId: pending.projectId, + launchRequest, + }); + } catch (err) { + console.warn("[pending-page] agent launch enqueue failed:", err); + } + } + collections.pendingWorkspaces.update(pendingId, (draft) => { draft.status = "succeeded"; draft.workspaceId = result.workspace?.id ?? null; @@ -117,6 +146,8 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { adoptWorktree, pending, pendingId, + enqueueAgentLaunch, + agentPresetsQuery.data, ]); } diff --git a/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts b/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts index 3f8c422f785..a4e185a4106 100644 --- a/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts +++ b/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { indexResolvedAgentConfigs, - resolveAgentConfigs, type ResolvedAgentConfig, + resolveAgentConfigs, } from "shared/utils/agent-settings"; import { buildAgentLaunchRequest } from "./buildAgentLaunchRequest"; import type { AgentLaunchSpec } from "./types"; @@ -41,9 +41,7 @@ describe("buildAgentLaunchRequest", () => { const req = buildAgentLaunchRequest( baseSpec({ agentId: "codex", - user: [ - { type: "text", text: "refactor the auth middleware" }, - ], + user: [{ type: "text", text: "refactor the auth middleware" }], }), getConfig("codex"), { workspaceId: "ws-1", source: "new-workspace" }, From 8c6f630a27cfaa44722469ab4316ab6b696bedc2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 11:39:21 -0700 Subject: [PATCH 18/42] Lint --- .../pending/$pendingId/buildForkAgentLaunch.test.ts | 8 ++------ .../_authenticated/_dashboard/pending/$pendingId/page.tsx | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) 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 index db31d6aa175..cd660c6030d 100644 --- 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 @@ -8,9 +8,7 @@ import { const PROJECT_ID = "proj-1"; function pendingBase( - overrides: Partial< - Parameters[0] - > = {}, + overrides: Partial[0]> = {}, ): Parameters[0] { return { projectId: PROJECT_ID, @@ -23,9 +21,7 @@ function pendingBase( describe("buildLaunchSourcesFromPending", () => { test("returns [] when everything is empty", () => { - expect(buildLaunchSourcesFromPending(pendingBase(), undefined)).toEqual( - [], - ); + expect(buildLaunchSourcesFromPending(pendingBase(), undefined)).toEqual([]); }); test("produces user-prompt source when prompt is non-empty", () => { 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 c89817421c7..6ea7fe9f257 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 @@ -6,17 +6,17 @@ 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 { useEnqueueAgentLaunch } from "renderer/hooks/useEnqueueAgentLaunch"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { clearAttachments, loadAttachments, } from "renderer/lib/pending-attachment-store"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { useAdoptWorktree } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree"; import { useCheckoutDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace"; import { useCreateDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace"; -import { useEnqueueAgentLaunch } from "renderer/hooks/useEnqueueAgentLaunch"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; From 0741690debc416cecf60c2882339029cc0b31cc6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 15 Apr 2026 11:40:40 -0700 Subject: [PATCH 19/42] docs(desktop): add v2 launch context reference doc Post-phase-1 reference: what shipped, manual + automated test plan, known gaps, prioritized follow-ups, and a file-layout map. Lives in apps/desktop/docs/ per AGENTS.md rule 7 (architecture docs). The original plan stays in plans/ since phases 2-6 are still unshipped. --- apps/desktop/docs/V2_LAUNCH_CONTEXT.md | 172 +++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 apps/desktop/docs/V2_LAUNCH_CONTEXT.md diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md new file mode 100644 index 00000000000..9361b53beb9 --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md @@ -0,0 +1,172 @@ +# 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 shipped (phase 1) + +V2 "fork" workspaces now launch an agent with the user's prompt, linked +issue/PR/task metadata, and attached files. Gaps 4 and 5 from +`V2_WORKSPACE_MODAL_GAPS.md` are closed; Gaps 3 and 6 remain open. + +### Pipeline + +``` +draft (modal) + → PendingWorkspaceRow + → buildForkAgentLaunch (pending page) + ├─ buildLaunchSourcesFromPending → LaunchSource[] + ├─ buildLaunchContext → LaunchContext + ├─ buildLaunchSpec → AgentLaunchSpec + └─ buildAgentLaunchRequest → AgentLaunchRequest (V1 shape) + → host-service.workspaceCreation.create (workspace exists) + → useEnqueueAgentLaunch (pending setup stashed) + → V1 terminal-adapter / chat-adapter (picks up on workspace mount) + → Agent runs in the worktree +``` + +### Files + +- `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. +- `shared/context/buildAgentLaunchRequest.ts` — V2 spec → V1 request bridge (base64 encoding, collision-safe filenames). +- `renderer/hooks/useEnqueueAgentLaunch/*` — wrap V1's `useWorkspaceInitStore.addPendingTerminalSetup`. +- `routes/.../pending/$pendingId/buildForkAgentLaunch.ts` — pure helper that runs the pipeline for the pending page. +- `routes/.../pending/$pendingId/page.tsx` — wires the enqueue after `createWorkspace` resolves. + +### 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. + +## Follow-ups (roughly in priority order) + +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) +``` From 53dcac89adf13ff10e204b2280922df43c33d5bf Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 11:53:34 -0700 Subject: [PATCH 20/42] chore(debug): add [v2-launch] console logs across the launch pipeline Temporary logs for manual testing: - pending page: what buildForkAgentLaunch returned + enqueue inputs. - useEnqueueAgentLaunch: stash / null-short-circuit. - WorkspaceInitEffects: every handleTerminalSetup + dispatch branch, launchAgentViaOrchestrator invocation. Grep devtools console on "[v2-launch]" to trace a full submit. Remove or soften once the dispatch path is dialed in. --- .../useEnqueueAgentLaunch.ts | 14 +++- .../_dashboard/pending/$pendingId/page.tsx | 74 ++++++++++++++----- .../main/components/WorkspaceInitEffects.tsx | 24 ++++++ bun.lock | 2 +- 4 files changed, 94 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts index 0bcde47c1a4..2aaec014f31 100644 --- a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts +++ b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts @@ -53,7 +53,19 @@ export function useEnqueueAgentLaunch(): ( return useCallback( (args) => { const setup = buildPendingSetup(args); - if (setup) addPendingTerminalSetup(setup); + if (setup) { + console.log("[v2-launch] useEnqueueAgentLaunch: stashing pending setup", { + workspaceId: setup.workspaceId, + projectId: setup.projectId, + agentLaunchKind: setup.agentLaunchRequest?.kind ?? null, + }); + addPendingTerminalSetup(setup); + } else { + console.warn( + "[v2-launch] useEnqueueAgentLaunch: null launchRequest — nothing to enqueue", + { workspaceId: args.workspaceId }, + ); + } }, [addPendingTerminalSetup], ); 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 6ea7fe9f257..e02f7013ad1 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 @@ -104,24 +104,62 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { // Enqueue the V2 agent launch for fork intent. The workspace's // terminal-adapter / chat-adapter picks up this pending setup when // the workspace mounts. - if ( - pending.intent === "fork" && - result.workspace?.id && - agentPresetsQuery.data - ) { - try { - const launchRequest = await buildForkAgentLaunch({ - pending, - attachments: loadedAttachments, - agentConfigs: agentPresetsQuery.data, - }); - enqueueAgentLaunch({ - workspaceId: result.workspace.id, - projectId: pending.projectId, - launchRequest, - }); - } catch (err) { - console.warn("[pending-page] agent launch enqueue failed:", err); + if (pending.intent === "fork") { + if (!result.workspace?.id) { + console.warn( + "[v2-launch] skip enqueue: createWorkspace returned no workspace.id", + ); + } else if (!agentPresetsQuery.data) { + console.warn( + "[v2-launch] skip enqueue: agentPresetsQuery.data not loaded yet", + { status: agentPresetsQuery.status }, + ); + } else { + try { + console.log("[v2-launch] building fork launch…", { + workspaceId: result.workspace.id, + projectId: pending.projectId, + promptLength: pending.prompt?.length ?? 0, + linkedIssueCount: pending.linkedIssues.length, + linkedPR: pending.linkedPR?.url ?? null, + attachmentCount: loadedAttachments?.length ?? 0, + agentPresetCount: agentPresetsQuery.data.length, + }); + const launchRequest = await buildForkAgentLaunch({ + pending, + attachments: loadedAttachments, + agentConfigs: agentPresetsQuery.data, + }); + console.log("[v2-launch] buildForkAgentLaunch returned:", { + kind: launchRequest?.kind ?? null, + agentType: launchRequest?.agentType ?? null, + command: + launchRequest?.kind === "terminal" + ? launchRequest.terminal.command.slice(0, 200) + : undefined, + initialPrompt: + launchRequest?.kind === "chat" + ? launchRequest.chat.initialPrompt?.slice(0, 200) + : undefined, + initialFiles: + launchRequest?.kind === "terminal" + ? launchRequest.terminal.initialFiles?.length ?? 0 + : launchRequest?.kind === "chat" + ? launchRequest.chat.initialFiles?.length ?? 0 + : 0, + }); + enqueueAgentLaunch({ + workspaceId: result.workspace.id, + projectId: pending.projectId, + launchRequest, + }); + console.log("[v2-launch] enqueueAgentLaunch called", { + workspaceId: result.workspace.id, + didEnqueue: launchRequest !== null, + }); + } catch (err) { + console.warn("[v2-launch] enqueue failed:", err); + } } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx index cdfb7111a30..f9fc7ba9737 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx @@ -77,6 +77,12 @@ export function WorkspaceInitEffects() { let request: AgentLaunchRequest; try { const resolved = resolveSetupLaunchRequest(setup); + console.log("[v2-launch] launchAgentViaOrchestrator called", { + workspaceId: setup.workspaceId, + targetPaneId, + resolvedKind: resolved?.kind ?? null, + agentType: resolved?.agentType ?? null, + }); if (!resolved) return false; request = targetPaneId && @@ -144,6 +150,24 @@ export function WorkspaceInitEffects() { ); const hasPresets = shouldApplyPreset && presets.length > 0; const { agentCommand, agentLaunchRequest } = setup; + console.log("[v2-launch] handleTerminalSetup", { + workspaceId: setup.workspaceId, + hasSetupScript, + hasPresets, + presetCount: presets.length, + hasAgentLaunchRequest: !!agentLaunchRequest, + hasAgentCommand: !!agentCommand, + agentLaunchKind: agentLaunchRequest?.kind ?? null, + branch: hasSetupScript && hasPresets + ? "setupScript+presets (agent writes to setup pane)" + : hasSetupScript + ? "setupScript only (agent writes to setup pane)" + : hasPresets + ? "presets only (agent new pane)" + : agentLaunchRequest || agentCommand + ? "agent only (new pane)" + : "no-op", + }); if (hasSetupScript && hasPresets) { const { tabId: setupTabId, paneId: setupPaneId } = addTab( diff --git a/bun.lock b/bun.lock index cde4c6c4a76..4aedae65728 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.5.3", + "version": "1.5.5", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", From 599277aec1938bf6ce7d73cd3e61ef41c0675331 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 12:11:40 -0700 Subject: [PATCH 21/42] docs(desktop): document pending-row-as-bus launch dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2 must own its own launch dispatch. V1's WorkspaceInitEffects → orchestrator → terminal-adapter path writes panes into V1's useTabsStore, which V2 doesn't render from, so launches dispatched through V1 land invisibly for V2 workspaces. Documents the replacement: pending-row-as-bus. Pending page produces terminalLaunch / chatLaunch fields on the collection-backed pending row; V2 workspace page mount-effect consumes them, opens a pane in the @superset/panes store, and wires PTY via workspaceTrpc. This mirrors the pattern V2 preset execution already uses (useV2PresetExecution): live-query a record, open a pane, call workspaceTrpc.terminal.ensureSession. Zero V1 primitives, zero new host-service work, and leaves a clean migration path to host-owned terminal launch when phase 5 ships. Adds a blocking follow-up (#0) for the dispatch rewrite; marks useEnqueueAgentLaunch + buildAgentLaunchRequest for removal. --- apps/desktop/docs/V2_LAUNCH_CONTEXT.md | 131 ++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 15 deletions(-) diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md index 9361b53beb9..308ef656ca5 100644 --- a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md @@ -3,13 +3,13 @@ Status as of PR #3467 (branch `v2-modal-agent-launch`). See `plans/v2-workspace-context-composition.md` for the full design. -## What shipped (phase 1) +## What's implemented (phase 1) -V2 "fork" workspaces now launch an agent with the user's prompt, linked -issue/PR/task metadata, and attached files. Gaps 4 and 5 from -`V2_WORKSPACE_MODAL_GAPS.md` are closed; Gaps 3 and 6 remain open. +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 +### Pipeline (composition) ``` draft (modal) @@ -18,23 +18,113 @@ draft (modal) ├─ buildLaunchSourcesFromPending → LaunchSource[] ├─ buildLaunchContext → LaunchContext ├─ buildLaunchSpec → AgentLaunchSpec - └─ buildAgentLaunchRequest → AgentLaunchRequest (V1 shape) - → host-service.workspaceCreation.create (workspace exists) - → useEnqueueAgentLaunch (pending setup stashed) - → V1 terminal-adapter / chat-adapter (picks up on workspace mount) - → Agent runs in the worktree + └─ consumer picks chat vs terminal based on the selected agent's kind ``` -### Files +## 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 → <worktree>/.superset/attachments/… │ +│ • pendingRow.terminalLaunch = { command, name } │ +│ │ +│ kind == "chat": │ +│ • pendingRow.chatLaunch = { │ +│ initialPrompt, initialFiles, model, taskSlug, │ +│ } │ +│ │ +│ 4. Navigate to /v2-workspace/<workspaceId> │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 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. -- `shared/context/buildAgentLaunchRequest.ts` — V2 spec → V1 request bridge (base64 encoding, collision-safe filenames). -- `renderer/hooks/useEnqueueAgentLaunch/*` — wrap V1's `useWorkspaceInitStore.addPendingTerminalSetup`. -- `routes/.../pending/$pendingId/buildForkAgentLaunch.ts` — pure helper that runs the pipeline for the pending page. -- `routes/.../pending/$pendingId/page.tsx` — wires the enqueue after `createWorkspace` resolves. +- `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 @@ -101,6 +191,17 @@ bun run scripts/demo-launch-spec.ts claude # just claude ## 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 From 8095d4e0b08e83102bf1cae90e31a6f9c04472a2 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 12:27:47 -0700 Subject: [PATCH 22/42] feat(desktop/v2): rewrite launch dispatch as pending-row-as-bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original step-8/9 wire-up stashed an AgentLaunchRequest in V1's useWorkspaceInitStore, expecting V1's WorkspaceInitEffects to dispatch. V1's orchestrator writes panes into useTabsStore — which V2 never renders from — so launches landed invisibly for V2 workspaces. This rewrite keeps V2 self-contained. After host-service.create resolves, the pending page runs the composer pipeline and stashes a terminalLaunch or chatLaunch on the pending row. The V2 workspace page's new useConsumePendingLaunch mount-effect live-queries that row, opens a pane in @superset/panes, and drives PTY via workspaceTrpc. Same pattern as useV2PresetExecution. Changes: - Schema: pendingWorkspaceSchema gains optional terminalLaunch and chatLaunch fields, cleared to null once consumed. - buildForkAgentLaunch returns a PendingLaunchBuild union (terminal with attachmentsToWrite / chat with inline initialFiles) instead of the V1 AgentLaunchRequest shape. - dispatchForkLaunch: new pending-page helper that runs the composer, writes attachments to .superset/attachments/ via workspaceTrpc .filesystem.writeFile for the terminal path, and applies the launch field to the pending row. - useConsumePendingLaunch: new V2-workspace-page mount effect. Reads row by workspaceId, opens pane in V2 store, calls workspaceTrpc .terminal.ensureSession with initialCommand for terminal launches, clears the field. - ChatPaneData gains a transient launchConfig slot. ChatPane and WorkspaceChatInterface thread initialLaunchConfig + onConsumeLaunchConfig through. After the V2 chat runtime auto-sends the initial message, it clears the pane's launchConfig. - Rip out useEnqueueAgentLaunch hook, buildAgentLaunchRequest, and the debug logs in WorkspaceInitEffects. 23 tests green for buildForkAgentLaunch / buildLaunchSourcesFromPending; type-check clean in the touched surface area. See apps/desktop/docs/V2_LAUNCH_CONTEXT.md "Dispatch architecture". --- .../hooks/useEnqueueAgentLaunch/index.ts | 1 - .../useEnqueueAgentLaunch.test.ts | 70 ------ .../useEnqueueAgentLaunch.ts | 72 ------ .../$pendingId/buildForkAgentLaunch.test.ts | 186 ++++++-------- .../$pendingId/buildForkAgentLaunch.ts | 232 ++++++++++++++++-- .../pending/$pendingId/dispatchForkLaunch.ts | 145 +++++++++++ .../_dashboard/pending/$pendingId/page.tsx | 91 +++---- .../hooks/useConsumePendingLaunch/index.ts | 1 + .../useConsumePendingLaunch.ts | 165 +++++++++++++ .../components/ChatPane/ChatPane.tsx | 8 +- .../ChatPaneInterface.tsx | 2 + .../WorkspaceChatInterface/types.ts | 6 + .../hooks/usePaneRegistry/usePaneRegistry.tsx | 8 + .../v2-workspace/$workspaceId/page.tsx | 2 + .../v2-workspace/$workspaceId/types.ts | 15 ++ .../dashboardSidebarLocal/schema.ts | 36 +++ .../main/components/WorkspaceInitEffects.tsx | 24 -- .../context/buildAgentLaunchRequest.test.ts | 209 ---------------- .../shared/context/buildAgentLaunchRequest.ts | 210 ---------------- 19 files changed, 710 insertions(+), 773 deletions(-) delete mode 100644 apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts delete mode 100644 apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts delete mode 100644 apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts delete mode 100644 apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts delete mode 100644 apps/desktop/src/shared/context/buildAgentLaunchRequest.ts diff --git a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts deleted file mode 100644 index 6726914f031..00000000000 --- a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useEnqueueAgentLaunch } from "./useEnqueueAgentLaunch"; diff --git a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts deleted file mode 100644 index 6f55d260dc4..00000000000 --- a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; -import { buildPendingSetup } from "./useEnqueueAgentLaunch"; - -function exampleRequest( - overrides: Partial<AgentLaunchRequest> = {}, -): AgentLaunchRequest { - return { - kind: "terminal", - workspaceId: "pending-workspace", - agentType: "codex", - source: "new-workspace", - terminal: { command: "codex", name: "Codex" }, - ...overrides, - } as AgentLaunchRequest; -} - -describe("buildPendingSetup", () => { - test("returns null when launchRequest is null", () => { - expect( - buildPendingSetup({ - workspaceId: "ws-1", - projectId: "proj-1", - launchRequest: null, - }), - ).toBeNull(); - }); - - test("rewrites launchRequest.workspaceId to the real id", () => { - const setup = buildPendingSetup({ - workspaceId: "ws-real-42", - projectId: "proj-1", - launchRequest: exampleRequest({ workspaceId: "pending-workspace" }), - }); - expect(setup?.workspaceId).toBe("ws-real-42"); - expect(setup?.agentLaunchRequest?.workspaceId).toBe("ws-real-42"); - }); - - test("passes projectId through", () => { - const setup = buildPendingSetup({ - workspaceId: "ws-1", - projectId: "proj-alpha", - launchRequest: exampleRequest(), - }); - expect(setup?.projectId).toBe("proj-alpha"); - }); - - test("initialCommands is null (V2 host-service handles setup scripts)", () => { - const setup = buildPendingSetup({ - workspaceId: "ws-1", - projectId: "proj-1", - launchRequest: exampleRequest(), - }); - expect(setup?.initialCommands).toBeNull(); - }); - - test("preserves non-workspaceId launchRequest fields (kind, agentType, chat, etc.)", () => { - const setup = buildPendingSetup({ - workspaceId: "ws-1", - projectId: "proj-1", - launchRequest: exampleRequest({ - agentType: "claude", - source: "mcp", - }), - }); - expect(setup?.agentLaunchRequest?.kind).toBe("terminal"); - expect(setup?.agentLaunchRequest?.agentType).toBe("claude"); - expect(setup?.agentLaunchRequest?.source).toBe("mcp"); - }); -}); diff --git a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts b/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts deleted file mode 100644 index 2aaec014f31..00000000000 --- a/apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/useEnqueueAgentLaunch.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; -import { useCallback } from "react"; -import { - type PendingTerminalSetup, - useWorkspaceInitStore, -} from "renderer/stores/workspace-init"; - -export interface EnqueueAgentLaunchArgs { - workspaceId: string; - projectId: string; - launchRequest: AgentLaunchRequest | null; -} - -/** - * Shape the pending-setup entry for a V2 launch. Returns null for an - * empty launch (nothing to stash). Exported for unit testing. - */ -export function buildPendingSetup( - args: EnqueueAgentLaunchArgs, -): PendingTerminalSetup | null { - if (!args.launchRequest) return null; - return { - workspaceId: args.workspaceId, - projectId: args.projectId, - initialCommands: null, - agentLaunchRequest: { - ...args.launchRequest, - workspaceId: args.workspaceId, - }, - }; -} - -/** - * V2 hook: stash a pending agent launch for a just-created workspace. - * - * When the workspace mounts, V1's terminal-adapter / chat-adapter read - * the pending setup and execute the launch. This is the same mechanism - * V1 uses (via useCreateWorkspace.mutateAsyncWithPendingSetup); V2's - * submit flow calls this directly after host-service.workspaceCreation - * returns the real workspaceId. - * - * Takes a V1-shaped AgentLaunchRequest (produced by - * buildAgentLaunchRequest in shared/context). Rewrites the request's - * workspaceId to the real id if it was built with a placeholder. - */ -export function useEnqueueAgentLaunch(): ( - args: EnqueueAgentLaunchArgs, -) => void { - const addPendingTerminalSetup = useWorkspaceInitStore( - (s) => s.addPendingTerminalSetup, - ); - - return useCallback( - (args) => { - const setup = buildPendingSetup(args); - if (setup) { - console.log("[v2-launch] useEnqueueAgentLaunch: stashing pending setup", { - workspaceId: setup.workspaceId, - projectId: setup.projectId, - agentLaunchKind: setup.agentLaunchRequest?.kind ?? null, - }); - addPendingTerminalSetup(setup); - } else { - console.warn( - "[v2-launch] useEnqueueAgentLaunch: null launchRequest — nothing to enqueue", - { workspaceId: args.workspaceId }, - ); - } - }, - [addPendingTerminalSetup], - ); -} 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 index cd660c6030d..1d93c2bfa1e 100644 --- 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 @@ -45,94 +45,12 @@ describe("buildLaunchSourcesFromPending", () => { expect(sources.filter((s) => s.kind === "user-prompt")).toEqual([]); }); - test("maps github-sourced linkedIssue to github-issue", () => { - const sources = buildLaunchSourcesFromPending( - pendingBase({ - linkedIssues: [ - { - source: "github", - url: "https://github.com/acme/repo/issues/1", - number: 1, - slug: "x", - title: "X", - state: "open", - }, - ], - }), - undefined, - ); - expect(sources).toEqual([ - { - kind: "github-issue", - url: "https://github.com/acme/repo/issues/1", - }, - ]); - }); - - test("maps internal-sourced linkedIssue with taskId to internal-task", () => { - const sources = buildLaunchSourcesFromPending( - pendingBase({ - linkedIssues: [ - { - source: "internal", - taskId: "TASK-42", - slug: "refactor-auth", - title: "Refactor auth", - }, - ], - }), - undefined, - ); - expect(sources).toEqual([{ kind: "internal-task", id: "TASK-42" }]); - }); - - test("adds github-pr source for linkedPR", () => { - const sources = buildLaunchSourcesFromPending( - pendingBase({ - linkedPR: { - prNumber: 200, - url: "https://github.com/acme/repo/pull/200", - title: "Rewrite auth", - state: "open", - }, - }), - undefined, - ); - expect(sources).toEqual([ - { - kind: "github-pr", - url: "https://github.com/acme/repo/pull/200", - }, - ]); - }); - - test("converts base64 data URL attachments to Uint8Array sources", () => { - 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(source.file.mediaType).toBe("text/plain"); - expect(Array.from(source.file.data)).toEqual([1, 2, 3]); - }); - - test("orders sources: user-prompt, issues/tasks, PR, attachments", () => { + 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: "internal", taskId: "T-1", slug: "s", title: "t" }, { source: "github", url: "https://x/issues/9", @@ -165,45 +83,61 @@ describe("buildLaunchSourcesFromPending", () => { "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 req = await buildForkAgentLaunch({ + const build = await buildForkAgentLaunch({ pending: pendingBase(), attachments: undefined, agentConfigs, }); - expect(req).toBeNull(); + expect(build).toBeNull(); }); test("returns null when there are no enabled agents", async () => { - const req = await buildForkAgentLaunch({ + const build = await buildForkAgentLaunch({ pending: pendingBase({ prompt: "hi" }), attachments: undefined, agentConfigs: [], }); - expect(req).toBeNull(); + expect(build).toBeNull(); }); - test("produces a terminal request for a prompt-only launch via default agent", async () => { - const req = await buildForkAgentLaunch({ + 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(req?.kind).toBe("terminal"); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - // getFallbackAgentId prefers "claude" when enabled - expect(req.agentType).toBe("claude"); - expect(req.source).toBe("new-workspace"); - expect(req.terminal.command).toContain("claude"); + 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 derives taskSlug in the request", async () => { - const req = await buildForkAgentLaunch({ + test("linked internal task renders into the command", async () => { + const build = await buildForkAgentLaunch({ pending: pendingBase({ prompt: "do it", linkedIssues: [ @@ -218,14 +152,12 @@ describe("buildForkAgentLaunch", () => { attachments: undefined, agentConfigs, }); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - // Terminal command doesn't surface taskSlug; ensure the prompt - // payload at least carries the task title. - expect(req.terminal.command).toContain("Refactor auth"); + if (build?.kind !== "terminal") throw new Error("wrong kind"); + expect(build.launch.command).toContain("Refactor auth"); }); - test("attachment bytes flow through to initialFiles as base64 data URL", async () => { - const req = await buildForkAgentLaunch({ + test("attachments produce disk-ready bytes + matching names", async () => { + const build = await buildForkAgentLaunch({ pending: pendingBase({ prompt: "fix" }), attachments: [ { @@ -236,9 +168,47 @@ describe("buildForkAgentLaunch", () => { ], agentConfigs, }); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - const file = req.terminal.initialFiles?.[0]; - expect(file?.filename).toBe("logs.txt"); - expect(file?.data).toBe("data:text/plain;base64,AQID"); + 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 index 1c7b48ac76a..c121d596292 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -1,15 +1,22 @@ -import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; -import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import { buildAgentLaunchRequest } from "shared/context/buildAgentLaunchRequest"; +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 { isTerminalAgentDefinition } from "@superset/shared/agent-catalog"; import { + buildPromptCommandFromAgentConfig, + getCommandFromAgentConfig, getFallbackAgentId, indexResolvedAgentConfigs, type ResolvedAgentConfig, @@ -31,27 +38,51 @@ export interface BuildForkAgentLaunchInputs { } /** - * Build a V1-shaped AgentLaunchRequest for the "fork" intent once the - * host-service workspace-create succeeds. Runs the V2 composer + - * buildLaunchSpec + buildAgentLaunchRequest pipeline over whatever - * metadata the pending row has. + * 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). * * Phase 1 note: issue / PR / task bodies are not fetched over HTTP yet * (host-service lacks a body endpoint). The resolver returns empty - * bodies — the agent sees the title/URL/task-slug metadata only. When - * host-service grows a getIssueContent / getPullRequestContent / - * getInternalTaskContent API, swap the resolver stubs here. + * bodies — the agent sees title/URL/task-slug metadata only. When + * host-service grows getIssueContent / getPullRequestContent / + * getInternalTaskContent, swap the resolver stubs here. */ export async function buildForkAgentLaunch( inputs: BuildForkAgentLaunchInputs, -): Promise<AgentLaunchRequest | null> { +): Promise<PendingLaunchBuild | null> { const agentId = getFallbackAgentId(inputs.agentConfigs); if (!agentId) return null; const agentConfig = indexResolvedAgentConfigs(inputs.agentConfigs).get( agentId, ); - if (!agentConfig) return null; + if (!agentConfig || !agentConfig.enabled) return null; const sources = buildLaunchSourcesFromPending( inputs.pending, @@ -73,12 +104,183 @@ export async function buildForkAgentLaunch( const spec = buildLaunchSpec(ctx, agentConfig); if (!spec) return null; - return buildAgentLaunchRequest(spec, agentConfig, { - workspaceId: "pending-workspace", - source: "new-workspace", + 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 { + const base64 = Buffer.from(part.data).toString("base64"); + return `data:${part.mediaType};base64,${base64}`; +} + +// --------------------------------------------------------------------------- +// 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, 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..ca195727518 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -0,0 +1,145 @@ +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> { + let build: Awaited<ReturnType<typeof buildForkAgentLaunch>>; + try { + build = await buildForkAgentLaunch({ + pending, + attachments: loadedAttachments, + agentConfigs, + }); + } catch (err) { + console.warn("[v2-launch] buildForkAgentLaunch failed:", err); + return; + } + + if (!build) return; + + if (build.kind === "chat") { + onApplyToRow({ chatLaunch: build.launch }); + return; + } + + const hostUrl = resolveHostUrl(pending.hostTarget, activeHostUrl); + if (!hostUrl) { + console.warn("[v2-launch] host-service URL not resolved; skip launch"); + return; + } + + try { + if (build.attachmentsToWrite.length > 0) { + await writeAttachmentsToWorktree({ + hostUrl, + workspaceId, + attachments: build.attachmentsToWrite, + }); + } + } catch (err) { + console.warn("[v2-launch] failed to write attachments:", err); + // keep going — terminal launch still useful even without files + } + + onApplyToRow({ terminalLaunch: build.launch }); +} + +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", + ); + return; + } + + 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: Buffer.from(attachment.data).toString("base64"), + }, + }); + } +} + +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 e02f7013ad1..2104ffb2c76 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 @@ -6,7 +6,6 @@ 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 { useEnqueueAgentLaunch } from "renderer/hooks/useEnqueueAgentLaunch"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -21,7 +20,7 @@ import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/u import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { buildForkAgentLaunch } from "./buildForkAgentLaunch"; +import { dispatchForkLaunch } from "./dispatchForkLaunch"; import { buildAdoptPayload, buildCheckoutPayload, @@ -54,8 +53,8 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { const createWorkspace = useCreateDashboardWorkspace(); const checkoutWorkspace = useCheckoutDashboardWorkspace(); const adoptWorktree = useAdoptWorktree(); - const enqueueAgentLaunch = useEnqueueAgentLaunch(); const agentPresetsQuery = electronTrpc.settings.getAgentPresets.useQuery(); + const { activeHostUrl } = useLocalHostService(); return useCallback(async () => { if (!pending) return; @@ -101,66 +100,32 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { } } - // Enqueue the V2 agent launch for fork intent. The workspace's - // terminal-adapter / chat-adapter picks up this pending setup when - // the workspace mounts. - if (pending.intent === "fork") { - if (!result.workspace?.id) { - console.warn( - "[v2-launch] skip enqueue: createWorkspace returned no workspace.id", - ); - } else if (!agentPresetsQuery.data) { - console.warn( - "[v2-launch] skip enqueue: agentPresetsQuery.data not loaded yet", - { status: agentPresetsQuery.status }, - ); - } else { - try { - console.log("[v2-launch] building fork launch…", { - workspaceId: result.workspace.id, - projectId: pending.projectId, - promptLength: pending.prompt?.length ?? 0, - linkedIssueCount: pending.linkedIssues.length, - linkedPR: pending.linkedPR?.url ?? null, - attachmentCount: loadedAttachments?.length ?? 0, - agentPresetCount: agentPresetsQuery.data.length, - }); - const launchRequest = await buildForkAgentLaunch({ - pending, - attachments: loadedAttachments, - agentConfigs: agentPresetsQuery.data, - }); - console.log("[v2-launch] buildForkAgentLaunch returned:", { - kind: launchRequest?.kind ?? null, - agentType: launchRequest?.agentType ?? null, - command: - launchRequest?.kind === "terminal" - ? launchRequest.terminal.command.slice(0, 200) - : undefined, - initialPrompt: - launchRequest?.kind === "chat" - ? launchRequest.chat.initialPrompt?.slice(0, 200) - : undefined, - initialFiles: - launchRequest?.kind === "terminal" - ? launchRequest.terminal.initialFiles?.length ?? 0 - : launchRequest?.kind === "chat" - ? launchRequest.chat.initialFiles?.length ?? 0 - : 0, + // 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. + if ( + pending.intent === "fork" && + result.workspace?.id && + agentPresetsQuery.data + ) { + await dispatchForkLaunch({ + workspaceId: result.workspace.id, + pending, + loadedAttachments, + agentConfigs: agentPresetsQuery.data, + activeHostUrl, + onApplyToRow: (patch) => { + collections.pendingWorkspaces.update(pendingId, (draft) => { + if (patch.terminalLaunch !== undefined) { + draft.terminalLaunch = patch.terminalLaunch; + } + if (patch.chatLaunch !== undefined) { + draft.chatLaunch = patch.chatLaunch; + } }); - enqueueAgentLaunch({ - workspaceId: result.workspace.id, - projectId: pending.projectId, - launchRequest, - }); - console.log("[v2-launch] enqueueAgentLaunch called", { - workspaceId: result.workspace.id, - didEnqueue: launchRequest !== null, - }); - } catch (err) { - console.warn("[v2-launch] enqueue failed:", err); - } - } + }, + }); } collections.pendingWorkspaces.update(pendingId, (draft) => { @@ -184,8 +149,8 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { adoptWorktree, pending, pendingId, - enqueueAgentLaunch, agentPresetsQuery.data, + 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..12d4245c75c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts @@ -0,0 +1,165 @@ +import type { WorkspaceStore } from "@superset/panes"; +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) return; + + const terminalKey = pending.terminalLaunch + ? `${pending.id}:terminal` + : null; + const chatKey = pending.chatLaunch ? `${pending.id}:chat` : null; + + if (terminalKey && !consumedRef.current.has(terminalKey)) { + consumedRef.current.add(terminalKey); + void consumeTerminalLaunch({ + pending, + store, + ensureSession: ensureSessionRef.current.mutateAsync, + clear: () => updateRow({ terminalLaunch: null }), + }); + } + + if (chatKey && !consumedRef.current.has(chatKey)) { + consumedRef.current.add(chatKey); + consumeChatLaunch({ + pending, + store, + clear: () => updateRow({ chatLaunch: null }), + }); + } + }, [pending, store, updateRow]); +} + +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) return; + + const terminalId = crypto.randomUUID(); + + try { + await ensureSession({ + terminalId, + workspaceId: pending.workspaceId, + initialCommand: launch.command, + }); + } catch (err) { + console.warn("[v2-launch] terminal ensureSession failed:", err); + return; + } + + const data: TerminalPaneData = { terminalId }; + store.getState().addTab({ + panes: [ + { + kind: "terminal", + titleOverride: launch.name, + data: data as PaneViewerData, + }, + ], + }); + clear(); +} + +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, + }, + }; + + store.getState().addTab({ + panes: [ + { + kind: "chat", + data: data as PaneViewerData, + }, + ], + }); + clear(); +} 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 71cdbc232cf..d4556919fa4 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 @@ -190,6 +190,7 @@ function getLaunchConfigKey( export function ChatPaneInterface({ sessionId, initialLaunchConfig, + onConsumeLaunchConfig, workspaceId, organizationId, cwd, @@ -701,6 +702,7 @@ export function ChatPaneInterface({ consumedLaunchConfigRef.current = launchConfigKey; delete autoLaunchAttemptsRef.current[launchConfigKey]; delete autoLaunchSessionLockRef.current[launchConfigKey]; + onConsumeLaunchConfig?.(); captureChatEvent("chat_message_sent", { session_id: targetSessionId, 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 a409ac7053f..e964935d8c2 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 @@ -299,10 +299,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 62c18d0bb07..5cd291e69fb 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 @@ -20,6 +20,7 @@ import { V2PresetsBar } from "./components/V2PresetsBar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; 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"; @@ -90,6 +91,7 @@ function WorkspaceContent({ workspaceId, projectId, }); + useConsumePendingLaunch({ workspaceId, store }); const paneRegistry = usePaneRegistry(workspaceId); const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); 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 8a9ba5145cb..4cc4cc52fad 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 @@ -11,6 +11,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/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index d715b8b2cc0..a0a76323f78 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 @@ -102,9 +102,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 @@ -144,6 +175,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/screens/main/components/WorkspaceInitEffects.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx index f9fc7ba9737..cdfb7111a30 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx @@ -77,12 +77,6 @@ export function WorkspaceInitEffects() { let request: AgentLaunchRequest; try { const resolved = resolveSetupLaunchRequest(setup); - console.log("[v2-launch] launchAgentViaOrchestrator called", { - workspaceId: setup.workspaceId, - targetPaneId, - resolvedKind: resolved?.kind ?? null, - agentType: resolved?.agentType ?? null, - }); if (!resolved) return false; request = targetPaneId && @@ -150,24 +144,6 @@ export function WorkspaceInitEffects() { ); const hasPresets = shouldApplyPreset && presets.length > 0; const { agentCommand, agentLaunchRequest } = setup; - console.log("[v2-launch] handleTerminalSetup", { - workspaceId: setup.workspaceId, - hasSetupScript, - hasPresets, - presetCount: presets.length, - hasAgentLaunchRequest: !!agentLaunchRequest, - hasAgentCommand: !!agentCommand, - agentLaunchKind: agentLaunchRequest?.kind ?? null, - branch: hasSetupScript && hasPresets - ? "setupScript+presets (agent writes to setup pane)" - : hasSetupScript - ? "setupScript only (agent writes to setup pane)" - : hasPresets - ? "presets only (agent new pane)" - : agentLaunchRequest || agentCommand - ? "agent only (new pane)" - : "no-op", - }); if (hasSetupScript && hasPresets) { const { tabId: setupTabId, paneId: setupPaneId } = addTab( diff --git a/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts b/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts deleted file mode 100644 index a4e185a4106..00000000000 --- a/apps/desktop/src/shared/context/buildAgentLaunchRequest.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - indexResolvedAgentConfigs, - type ResolvedAgentConfig, - resolveAgentConfigs, -} from "shared/utils/agent-settings"; -import { buildAgentLaunchRequest } from "./buildAgentLaunchRequest"; -import type { AgentLaunchSpec } 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; -} - -const PNG_BYTES = new Uint8Array([137, 80, 78, 71]); - -function baseSpec(overrides: Partial<AgentLaunchSpec> = {}): AgentLaunchSpec { - return { - agentId: "claude", - system: [], - user: [{ type: "text", text: "hello" }], - attachments: [], - taskSlug: undefined, - ...overrides, - }; -} - -describe("buildAgentLaunchRequest", () => { - test("returns null for agentId 'none'", () => { - const req = buildAgentLaunchRequest( - baseSpec({ agentId: "none" as never }), - getConfig("claude"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - expect(req).toBeNull(); - }); - - test("terminal: flattens user text into buildPromptCommand", () => { - const req = buildAgentLaunchRequest( - baseSpec({ - agentId: "codex", - user: [{ type: "text", text: "refactor the auth middleware" }], - }), - getConfig("codex"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - expect(req?.kind).toBe("terminal"); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - expect(req.workspaceId).toBe("ws-1"); - expect(req.agentType).toBe("codex"); - expect(req.source).toBe("new-workspace"); - // Command is rendered via buildPromptCommandFromAgentConfig — contains codex CLI base - expect(req.terminal.command).toContain("codex"); - expect(req.terminal.name).toBe("Codex"); - }); - - test("chat: user text becomes initialPrompt + taskSlug flows through", () => { - const req = buildAgentLaunchRequest( - baseSpec({ - agentId: "superset-chat", - user: [{ type: "text", text: "refactor" }], - taskSlug: "refactor-auth", - }), - getConfig("superset-chat"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - expect(req?.kind).toBe("chat"); - if (req?.kind !== "chat") throw new Error("wrong kind"); - expect(req.chat.initialPrompt).toBe("refactor"); - expect(req.chat.taskSlug).toBe("refactor-auth"); - }); - - test("terminal: inline image in user flattens to path ref with assigned filename", () => { - const req = buildAgentLaunchRequest( - baseSpec({ - agentId: "codex", - user: [ - { type: "text", text: "see this:" }, - { type: "image", data: PNG_BYTES, mediaType: "image/png" }, - { type: "text", text: "fix it" }, - ], - }), - getConfig("codex"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - // Command should contain the inline path ref - expect(req.terminal.command).toContain("attachment_1"); - // Same filename in initialFiles[] - expect(req.terminal.initialFiles).toHaveLength(1); - expect(req.terminal.initialFiles?.[0]?.filename).toBe("attachment_1"); - }); - - test("terminal: explicit attachment keeps its original filename", () => { - const req = buildAgentLaunchRequest( - baseSpec({ - agentId: "codex", - attachments: [ - { - type: "file", - data: new Uint8Array([1, 2, 3]), - mediaType: "text/plain", - filename: "logs.txt", - }, - ], - }), - getConfig("codex"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - expect(req.terminal.initialFiles?.[0]?.filename).toBe("logs.txt"); - }); - - test("terminal: dedupes colliding filenames across user + attachments", () => { - const req = buildAgentLaunchRequest( - baseSpec({ - agentId: "codex", - user: [ - { - type: "file", - data: new Uint8Array([1]), - mediaType: "text/plain", - filename: "logs.txt", - }, - ], - attachments: [ - { - type: "file", - data: new Uint8Array([2]), - mediaType: "text/plain", - filename: "logs.txt", - }, - ], - }), - getConfig("codex"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - const filenames = req.terminal.initialFiles?.map((f) => f.filename); - expect(new Set(filenames).size).toBe(filenames?.length ?? 0); - }); - - test("chat: attachments are converted to base64 data URLs", () => { - const req = buildAgentLaunchRequest( - baseSpec({ - agentId: "superset-chat", - attachments: [ - { - type: "file", - data: new Uint8Array([1, 2, 3]), - mediaType: "text/plain", - filename: "logs.txt", - }, - ], - }), - getConfig("superset-chat"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - if (req?.kind !== "chat") throw new Error("wrong kind"); - const file = req.chat.initialFiles?.[0]; - expect(file?.data).toMatch(/^data:text\/plain;base64,/); - // base64 of [1,2,3] - expect(file?.data).toBe("data:text/plain;base64,AQID"); - }); - - test("chat: initialPrompt includes inline file/image refs", () => { - const req = buildAgentLaunchRequest( - baseSpec({ - agentId: "superset-chat", - user: [ - { type: "text", text: "look at" }, - { - type: "file", - data: new Uint8Array([1]), - mediaType: "text/plain", - filename: "trace.log", - }, - ], - }), - getConfig("superset-chat"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - if (req?.kind !== "chat") throw new Error("wrong kind"); - expect(req.chat.initialPrompt).toContain("trace.log"); - }); - - test("empty user content + empty attachments → still produces a valid launch (uses command without prompt)", () => { - const req = buildAgentLaunchRequest( - baseSpec({ agentId: "codex", user: [] }), - getConfig("codex"), - { workspaceId: "ws-1", source: "new-workspace" }, - ); - expect(req?.kind).toBe("terminal"); - if (req?.kind !== "terminal") throw new Error("wrong kind"); - expect(req.terminal.command).toBeTruthy(); - }); - - test("uses passed workspaceId + source verbatim", () => { - const req = buildAgentLaunchRequest( - baseSpec({ agentId: "codex" }), - getConfig("codex"), - { workspaceId: "some-workspace-42", source: "mcp" }, - ); - expect(req?.workspaceId).toBe("some-workspace-42"); - expect(req?.source).toBe("mcp"); - }); -}); diff --git a/apps/desktop/src/shared/context/buildAgentLaunchRequest.ts b/apps/desktop/src/shared/context/buildAgentLaunchRequest.ts deleted file mode 100644 index 0db355cebb4..00000000000 --- a/apps/desktop/src/shared/context/buildAgentLaunchRequest.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { - AgentLaunchRequest, - AgentLaunchSource, -} from "@superset/shared/agent-launch"; -import { - buildPromptCommandFromAgentConfig, - getCommandFromAgentConfig, - type ResolvedAgentConfig, - type TerminalResolvedAgentConfig, -} from "shared/utils/agent-settings"; -import type { AgentLaunchSpec, ContentPart } from "./types"; - -interface BuildOpts { - workspaceId: string; - source: AgentLaunchSource; -} - -/** - * Bridge V2 AgentLaunchSpec into the V1 AgentLaunchRequest shape so the - * existing terminal-adapter / chat-adapter infrastructure can consume - * it verbatim. No new IPC wiring needed. - * - * Responsibilities: - * - Assign collision-safe filenames across all binary parts (inline in - * user + explicit attachments) so the prompt text's path refs match - * what the adapter writes to disk. - * - Flatten spec.user to markdown text, with file/image parts rendered - * as `![filename](.superset/attachments/filename)` at their inline - * position — preserves editor order for CLI agents. - * - Convert Uint8Array binary data to base64 data URLs (V1 wire format). - * - Chat: initialPrompt = flattened text. - * - Terminal: command = buildPromptCommandFromAgentConfig(flattened text). - * - * Base64 encoding happens at this boundary only — internal plumbing - * stays on Uint8Array. - */ -export function buildAgentLaunchRequest( - spec: AgentLaunchSpec, - agentConfig: ResolvedAgentConfig, - opts: BuildOpts, -): AgentLaunchRequest | null { - if (spec.agentId === "none" || !agentConfig.enabled) return null; - - const assigned = assignFilenames(spec); - const initialFiles = assigned.length > 0 ? assigned.map(toV1File) : undefined; - const promptText = flattenUserContent(spec.user, assigned); - - if (agentConfig.kind === "chat") { - return { - kind: "chat", - workspaceId: opts.workspaceId, - agentType: agentConfig.id, - source: opts.source, - chat: { - initialPrompt: promptText || undefined, - initialFiles, - model: agentConfig.model, - taskSlug: spec.taskSlug, - }, - }; - } - - const command = buildTerminalCommand(agentConfig, promptText); - if (!command) return null; - return { - kind: "terminal", - workspaceId: opts.workspaceId, - agentType: agentConfig.id, - source: opts.source, - terminal: { - command, - name: agentConfig.label, - initialFiles, - }, - }; -} - -function buildTerminalCommand( - config: TerminalResolvedAgentConfig, - prompt: string, -): string | null { - if (!prompt.trim()) return getCommandFromAgentConfig(config); - return buildPromptCommandFromAgentConfig({ - prompt, - randomId: crypto.randomUUID(), - config, - }); -} - -// ------------------------------------------------------------------------- -// Filename assignment (collision-safe across inline + explicit attachments) -// ------------------------------------------------------------------------- - -type BinaryPart = Exclude<ContentPart, { type: "text" }>; - -interface AssignedBinary { - part: BinaryPart; - filename: string; - /** True if this binary appeared inline within spec.user */ - inline: boolean; - /** Index within its owning array — used to identify user-inline binaries when flattening */ - inlineIndex?: number; -} - -function assignFilenames(spec: AgentLaunchSpec): AssignedBinary[] { - const used = new Set<string>(); - const out: AssignedBinary[] = []; - - spec.user.forEach((part, index) => { - if (part.type === "text") return; - out.push({ - part, - filename: nextName(part, used, out.length), - inline: true, - inlineIndex: index, - }); - }); - - for (const part of spec.attachments) { - if (part.type === "text") continue; - out.push({ - part, - filename: nextName(part, used, out.length), - inline: false, - }); - } - - return out; -} - -function nextName( - part: BinaryPart, - used: Set<string>, - fallbackIndex: number, -): string { - const raw = part.type === "file" ? part.filename : undefined; - const sanitized = raw ? sanitize(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 sanitize(filename: string): string { - const cleaned = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); - return cleaned.trim() ? cleaned : ""; -} - -// ------------------------------------------------------------------------- -// Flatten user content to markdown text with inline file/image refs -// ------------------------------------------------------------------------- - -function flattenUserContent( - user: ContentPart[], - assigned: AssignedBinary[], -): string { - const inlineByIndex = new Map<number, string>(); - for (const a of assigned) { - if (a.inline && a.inlineIndex !== undefined) { - inlineByIndex.set(a.inlineIndex, a.filename); - } - } - - 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(); -} - -// ------------------------------------------------------------------------- -// Base64 conversion at the V1 wire boundary -// ------------------------------------------------------------------------- - -function toV1File(entry: AssignedBinary): { - data: string; - mediaType: string; - filename?: string; -} { - const { part, filename } = entry; - const base64 = Buffer.from(part.data).toString("base64"); - return { - data: `data:${part.mediaType};base64,${base64}`, - mediaType: part.mediaType, - filename, - }; -} From 44c8cf2dd77b6164d1e52029f5b042f89c7245b6 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 12:53:20 -0700 Subject: [PATCH 23/42] docs(desktop): add V2_LAUNCH_TEST_PLAN.md Structured manual test checklist for the V2 launch dispatch pipeline: terminal + chat happy paths, pending-row lifecycle, failure paths, source-mapping edge cases, custom agents, cross-pane behavior, V1 regression. Paired with copy-pasteable fixtures on ~/Desktop/v2-launch-test-artifacts/ (trace.log, notes.md, sample.png, prompts.txt, README) for drag-and- drop testing. --- apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md | 144 +++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md 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 From c372c313c73f3a8255dd67a194d3548328a036cf Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 14:42:51 -0700 Subject: [PATCH 24/42] chore(debug): add url probe + submit logs for v2 attachment flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs the blob/data URLs we get from the PromptInput provider at submit time, then does a fetch() probe on each URL before storeAttachments runs. Lets us see whether the URL is already dead when useSubmitWorkspace fires — which would confirm a pre-submit revocation (as opposed to a race inside storeAttachments itself). Not a fix. Remove once the root cause is nailed down. --- .../$pendingId/buildForkAgentLaunch.test.ts | 4 ++- .../$pendingId/buildForkAgentLaunch.ts | 10 +++--- .../$pendingId/buildIntentPayload.test.ts | 2 ++ .../pending/$pendingId/dispatchForkLaunch.ts | 5 +-- .../_dashboard/pending/$pendingId/page.tsx | 2 +- .../ChatPaneInterface.tsx | 1 + .../useSubmitWorkspace/useSubmitWorkspace.ts | 36 +++++++++++++++++++ .../AgentCard/agent-card.utils.test.ts | 4 +++ .../src/shared/context/composer.test.ts | 4 +-- 9 files changed, 57 insertions(+), 11 deletions(-) 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 index 1d93c2bfa1e..5b57cc209ab 100644 --- 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 @@ -179,7 +179,9 @@ describe("buildForkAgentLaunch", () => { test("chat agent → chat launch with initialPrompt + files", async () => { const chatOnlyConfigs = agentConfigs.map((c) => - c.id === "superset-chat" ? { ...c, enabled: true } : { ...c, enabled: false }, + c.id === "superset-chat" + ? { ...c, enabled: true } + : { ...c, enabled: false }, ); const build = await buildForkAgentLaunch({ pending: pendingBase({ prompt: "help me refactor" }), 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 index c121d596292..e7c27ea7aef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -1,3 +1,4 @@ +import { isTerminalAgentDefinition } from "@superset/shared/agent-catalog"; import type { PendingChatLaunch, PendingTerminalLaunch, @@ -13,7 +14,6 @@ import type { LaunchSource, ResolveCtx, } from "shared/context/types"; -import { isTerminalAgentDefinition } from "@superset/shared/agent-catalog"; import { buildPromptCommandFromAgentConfig, getCommandFromAgentConfig, @@ -195,13 +195,13 @@ function buildChatLaunch( function extractTextParts(parts: ContentPart[]): string[] { return parts - .filter((p): p is Extract<ContentPart, { type: "text" }> => p.type === "text") + .filter( + (p): p is Extract<ContentPart, { type: "text" }> => p.type === "text", + ) .map((p) => p.text); } -function toBase64DataUrl( - part: Exclude<ContentPart, { type: "text" }>, -): string { +function toBase64DataUrl(part: Exclude<ContentPart, { type: "text" }>): string { const base64 = Buffer.from(part.data).toString("base64"); return `data:${part.mediaType};base64,${base64}`; } 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 index ca195727518..d9d595a4b82 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -108,8 +108,9 @@ async function writeAttachmentsToWorktree({ }): Promise<void> { const client = getHostServiceClientByUrl(hostUrl); const workspace = await client.workspace.get.query({ id: workspaceId }); - const worktreePath: string | undefined = (workspace as { worktreePath?: string }) - .worktreePath; + const worktreePath: string | undefined = ( + workspace as { worktreePath?: string } + ).worktreePath; if (!worktreePath) { console.warn( "[v2-launch] workspace has no worktreePath; skipping attachments", 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 2104ffb2c76..d21dfd4b8f2 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 @@ -20,13 +20,13 @@ import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/u import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { dispatchForkLaunch } from "./dispatchForkLaunch"; import { buildAdoptPayload, buildCheckoutPayload, buildForkPayload, } from "./buildIntentPayload"; import { buildSetupPaneLayout } from "./buildSetupPaneLayout"; +import { dispatchForkLaunch } from "./dispatchForkLaunch"; /** * Pending workspace progress page. 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 d4556919fa4..46369b9288e 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 @@ -752,6 +752,7 @@ export function ChatPaneInterface({ setRuntimeErrorMessage, onUserMessageSubmitted, thinkingLevel, + onConsumeLaunchConfig, ]); const handleStop = useCallback( 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..164ad473c17 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 @@ -29,10 +29,46 @@ export function useSubmitWorkspace(projectId: string | null) { const pendingId = crypto.randomUUID(); const detachedFiles = attachments.takeFiles(); + console.log("[v2-launch] submit: detachedFiles", { + count: detachedFiles.length, + entries: detachedFiles.map((f) => ({ + id: f.id, + mediaType: f.mediaType, + filename: f.filename, + urlPrefix: f.url?.slice(0, 30), + urlKind: f.url?.startsWith("blob:") + ? "blob" + : f.url?.startsWith("data:") + ? "data" + : "other", + })), + }); if (detachedFiles.length > 0) { + // Probe each URL before handing to storeAttachments so we know if + // they're alive at submit-time (they should be — the provider + // cleanup-on-unmount effect hasn't fired yet). + for (const file of detachedFiles) { + try { + const probe = await fetch(file.url, { method: "GET" }); + console.log("[v2-launch] submit: url probe", { + id: file.id, + ok: probe.ok, + status: probe.status, + size: probe.headers.get("content-length"), + }); + } catch (err) { + console.warn("[v2-launch] submit: url probe FAILED", { + id: file.id, + url: file.url, + error: err instanceof Error ? err.message : String(err), + }); + } + } try { await storeAttachments(pendingId, detachedFiles); + console.log("[v2-launch] submit: storeAttachments OK", { pendingId }); } catch (err) { + console.error("[v2-launch] submit: storeAttachments FAILED", err); toast.error( err instanceof Error ? err.message : "Failed to store attachments", ); 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/composer.test.ts b/apps/desktop/src/shared/context/composer.test.ts index b2c7b965f66..bd4a4d14237 100644 --- a/apps/desktop/src/shared/context/composer.test.ts +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -20,7 +20,7 @@ function makeContributor<K extends LaunchSource["kind"]>( description: kind, requiresQuery: false, resolve: (source) => resolver(source), - }; + } as ContextContributor<Extract<LaunchSource, { kind: K }>>; } function registry( @@ -36,7 +36,7 @@ function registry( kind: "user-prompt", scope: "user", label: "Prompt", - content: [{ type: "text", text: s.text }], + content: s.content, })), "github-issue": makeContributor("github-issue", async (s) => ({ id: `issue:${s.url}`, From 33730ff01d0665bb4b626bbdfe18cf400c9c67cf Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 14:54:21 -0700 Subject: [PATCH 25/42] fix(desktop/v2): pass converted files through PromptInput onSubmit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "Failed to fetch" attachment toast: the PromptInput library calls clearComposer() before invoking onSubmit, which revokes all blob: URLs stored in the provider. Our useSubmitWorkspace was reading attachments back from the provider via takeFiles() after that — so it got file entries whose URLs had just been invalidated. The library already does the blob→data-URL conversion itself and passes the converted files into onSubmit's message arg. Use them directly: - useSubmitWorkspace now takes `files: SubmitAttachment[]` as an explicit argument. Drops the `useProviderAttachments()` dependency. - handlePromptSubmit receives `{text, files}` from PromptInput and forwards the files. - The existing Cmd+Enter keyboard fallback calls handleCreate() without files (unchanged behavior for the no-attachments path; the PromptInput's own Enter handler takes the file-carrying path). --- .../PromptGroup/PromptGroup.tsx | 24 ++- .../useSubmitWorkspace/useSubmitWorkspace.ts | 137 +++++++----------- 2 files changed, 72 insertions(+), 89 deletions(-) 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 164ad473c17..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,108 +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(); - console.log("[v2-launch] submit: detachedFiles", { - count: detachedFiles.length, - entries: detachedFiles.map((f) => ({ - id: f.id, - mediaType: f.mediaType, - filename: f.filename, - urlPrefix: f.url?.slice(0, 30), - urlKind: f.url?.startsWith("blob:") - ? "blob" - : f.url?.startsWith("data:") - ? "data" - : "other", - })), - }); - if (detachedFiles.length > 0) { - // Probe each URL before handing to storeAttachments so we know if - // they're alive at submit-time (they should be — the provider - // cleanup-on-unmount effect hasn't fired yet). - for (const file of detachedFiles) { + if (files.length > 0) { try { - const probe = await fetch(file.url, { method: "GET" }); - console.log("[v2-launch] submit: url probe", { - id: file.id, - ok: probe.ok, - status: probe.status, - size: probe.headers.get("content-length"), - }); + await storeAttachments(pendingId, files); } catch (err) { - console.warn("[v2-launch] submit: url probe FAILED", { - id: file.id, - url: file.url, - error: err instanceof Error ? err.message : String(err), - }); - } - } - try { - await storeAttachments(pendingId, detachedFiles); - console.log("[v2-launch] submit: storeAttachments OK", { pendingId }); - } catch (err) { - console.error("[v2-launch] submit: storeAttachments FAILED", 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); + 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], + ); } From c09c988b1f319cfa52cf9191dc6d677a597088dc Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 15:31:29 -0700 Subject: [PATCH 26/42] refactor(desktop): use dexie for the pending-attachment store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior hand-rolled IDB wrapper had two transaction-lifecycle bugs: 1. storeAttachments opened a readwrite transaction, then awaited fetch() on each file before calling store.put() — IDB auto-commits when the event loop yields with no pending requests, so the first put() fired against a finished transaction ("The transaction has finished."). 2. The same file (150+ lines of raw IDB callback plumbing) is exactly the shape of code where this class of bug keeps reappearing as the flow evolves. Swap to Dexie 4 — the de-facto IndexedDB wrapper for apps (~11.9k⭐, actively maintained, typed, handles transaction lifecycle correctly). - storeAttachments: resolve blobs async outside any tx, then bulkPut() in one shot. - loadAttachments / clearAttachments: where("key").startsWith(prefix). - File collapses from ~150 to ~90 lines, no raw transactions, no cursor dance. Behavior is identical from the caller's side. Schema version 1; Dexie will open the existing database transparently (same DB name). --- apps/desktop/package.json | 2 +- .../renderer/lib/pending-attachment-store.ts | 115 ++++++------------ bun.lock | 6 +- 3 files changed, 43 insertions(+), 80 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7cb0c7231c..54a843ab4c7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -158,6 +158,7 @@ "date-fns": "^4.1.0", "default-shell": "^2.2.0", "detect-libc": "2.0.4", + "dexie": "^4.4.2", "dnd-core": "^16.0.1", "dotenv": "^17.3.1", "drizzle-orm": "0.45.1", @@ -171,7 +172,6 @@ "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", "http-proxy": "^1.18.1", - "idb": "^8.0.3", "idb-keyval": "^6.2.2", "jose": "^6.1.3", "libsql": "0.5.22", 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/bun.lock b/bun.lock index 4aedae65728..a0e6b439303 100644 --- a/bun.lock +++ b/bun.lock @@ -235,6 +235,7 @@ "date-fns": "^4.1.0", "default-shell": "^2.2.0", "detect-libc": "2.0.4", + "dexie": "^4.4.2", "dnd-core": "^16.0.1", "dotenv": "^17.3.1", "drizzle-orm": "0.45.1", @@ -248,7 +249,6 @@ "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", "http-proxy": "^1.18.1", - "idb": "^8.0.3", "idb-keyval": "^6.2.2", "jose": "^6.1.3", "libsql": "0.5.22", @@ -3479,6 +3479,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@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], @@ -3975,8 +3977,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=="], From a4b0d4ee65ed4928319c6ffaf129b29556d4dc0d Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 16:15:00 -0700 Subject: [PATCH 27/42] chore(debug): add verbose [v2-launch] logs to dispatch + consume paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traces: - dispatchForkLaunch start / built / chatLaunch-applied / terminalLaunch-applied - useConsumePendingLaunch tick (live-query fires) + whether terminalKey / chatKey are already consumed - consumeTerminalLaunch ensureSession + addTab + clear - consumeChatLaunch addTab + clear Grep devtools on "[v2-launch]" through the full submit -> open-workspace flow. Lets us pin where dispatch stalls when no pane appears. Temporary — remove once the end-to-end flow is nailed down. --- .../pending/$pendingId/dispatchForkLaunch.ts | 30 +++++++++++- .../useConsumePendingLaunch.ts | 47 +++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) 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 index d9d595a4b82..57163f2ce45 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -44,6 +44,13 @@ export async function dispatchForkLaunch({ activeHostUrl, onApplyToRow, }: DispatchForkLaunchInputs): Promise<void> { + console.log("[v2-launch] dispatchForkLaunch: start", { + workspaceId, + projectId: pending.projectId, + attachmentCount: loadedAttachments?.length ?? 0, + agentConfigCount: agentConfigs.length, + }); + let build: Awaited<ReturnType<typeof buildForkAgentLaunch>>; try { build = await buildForkAgentLaunch({ @@ -56,10 +63,28 @@ export async function dispatchForkLaunch({ return; } - if (!build) 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"); + return; + } if (build.kind === "chat") { onApplyToRow({ chatLaunch: build.launch }); + console.log("[v2-launch] dispatchForkLaunch: chatLaunch applied to row"); return; } @@ -83,6 +108,9 @@ export async function dispatchForkLaunch({ } onApplyToRow({ terminalLaunch: build.launch }); + console.log("[v2-launch] dispatchForkLaunch: terminalLaunch applied to row", { + workspaceId, + }); } function resolveHostUrl( 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 index 12d4245c75c..db037843f28 100644 --- 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 @@ -59,15 +59,35 @@ export function useConsumePendingLaunch({ ); useEffect(() => { - if (!pending) return; + 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, @@ -78,13 +98,14 @@ export function useConsumePendingLaunch({ 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]); + }, [pending, store, updateRow, workspaceId]); } async function consumeTerminalLaunch({ @@ -103,9 +124,20 @@ async function consumeTerminalLaunch({ clear: () => void; }): Promise<void> { const launch = pending.terminalLaunch; - if (!launch || !pending.workspaceId) return; + if (!launch || !pending.workspaceId) { + console.warn("[v2-launch] consumeTerminalLaunch: bailing", { + hasLaunch: !!launch, + hasWorkspaceId: !!pending.workspaceId, + }); + return; + } const terminalId = crypto.randomUUID(); + console.log("[v2-launch] consumeTerminalLaunch: ensureSession", { + terminalId, + workspaceId: pending.workspaceId, + commandPreview: launch.command.slice(0, 120), + }); try { await ensureSession({ @@ -114,11 +146,12 @@ async function consumeTerminalLaunch({ initialCommand: launch.command, }); } catch (err) { - console.warn("[v2-launch] terminal ensureSession failed:", err); + console.warn("[v2-launch] consumeTerminalLaunch: ensureSession failed:", err); return; } const data: TerminalPaneData = { terminalId }; + console.log("[v2-launch] consumeTerminalLaunch: addTab", { terminalId }); store.getState().addTab({ panes: [ { @@ -129,6 +162,7 @@ async function consumeTerminalLaunch({ ], }); clear(); + console.log("[v2-launch] consumeTerminalLaunch: done + cleared"); } function consumeChatLaunch({ @@ -153,6 +187,10 @@ function consumeChatLaunch({ }, }; + console.log("[v2-launch] consumeChatLaunch: addTab", { + hasPrompt: !!launch.initialPrompt, + fileCount: launch.initialFiles?.length ?? 0, + }); store.getState().addTab({ panes: [ { @@ -162,4 +200,5 @@ function consumeChatLaunch({ ], }); clear(); + console.log("[v2-launch] consumeChatLaunch: done + cleared"); } From 29ac89bab42c164d4661451ba23629737d3ae610 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 16:18:23 -0700 Subject: [PATCH 28/42] fix(desktop/v2): replace Buffer with browser-native base64 in renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron renderer doesn't expose Node's `Buffer` global (nodeIntegration off). The fork-launch dispatch path and buildForkAgentLaunch were both using `Buffer.from(...).toString("base64")` / `Buffer.from(base64, "base64")` for binary <-> base64 conversion, which ReferenceError'd at runtime. Swap to standards-based `btoa` / `atob` + a small byte <-> binary-string helper. Works in renderer and Bun alike. Applies to: - dataUrlAttachmentToBytes (buildForkAgentLaunch.ts) — decode attachment data URL into Uint8Array. - toBase64DataUrl (buildForkAgentLaunch.ts) — encode chat-bound files for ChatLaunchConfig.initialFiles. - writeAttachmentsToWorktree (dispatchForkLaunch.ts) — encode bytes for host-service filesystem.writeFile's base64 content variant. --- .../$pendingId/buildForkAgentLaunch.ts | 20 ++++++++++++++++--- .../pending/$pendingId/dispatchForkLaunch.ts | 10 +++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) 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 index e7c27ea7aef..9845232207a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -202,8 +202,22 @@ function extractTextParts(parts: ContentPart[]): string[] { } function toBase64DataUrl(part: Exclude<ContentPart, { type: "text" }>): string { - const base64 = Buffer.from(part.data).toString("base64"); - return `data:${part.mediaType};base64,${base64}`; + 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; } // --------------------------------------------------------------------------- @@ -321,7 +335,7 @@ function dataUrlAttachmentToBytes(loaded: LoadedAttachment): AttachmentFile { const match = loaded.data.match(/^data:[^;]+;base64,(.+)$/); const base64 = match?.[1] ?? ""; return { - data: Uint8Array.from(Buffer.from(base64, "base64")), + data: base64ToBytes(base64), mediaType: loaded.mediaType, filename: loaded.filename, }; 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 index 57163f2ce45..92940969ff1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -162,12 +162,20 @@ async function writeAttachmentsToWorktree({ absolutePath: joinPath(dir, attachment.filename), content: { kind: "base64", - data: Buffer.from(attachment.data).toString("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}`; From 6a648c67c3a5ca484599b3909e29a4201b2a0189 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 16:51:13 -0700 Subject: [PATCH 29/42] docs(desktop): capture v2-launch footgun backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven items we caught during manual testing and intentionally deferred: 1. Deep solve for binary transport (blob URL / base64 fragility) 2. Reload-mid-launch spawns duplicate PTY (key terminalId off pending row) 3. Silent failure in consume hook — add toast 4. joinPath assumes POSIX — breaks for Windows hosts (phase 5) 5. Dexie schema coupling with pre-existing IDB store 6. PendingTerminalLaunch.attachmentNames unused by consumer 7. Remove [v2-launch] debug logs once flow is stable Tracked in V2_LAUNCH_CONTEXT.md "Known footguns to revisit". None are blocking phase-1 behavior; all have notes on the proper fix. --- apps/desktop/docs/V2_LAUNCH_CONTEXT.md | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md index 308ef656ca5..329cccc3ac9 100644 --- a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md @@ -189,6 +189,60 @@ bun run scripts/demo-launch-spec.ts claude # just claude 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 — From 8c420eabbf969588369c8564f53425feeacb6204 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 16:54:51 -0700 Subject: [PATCH 30/42] feat(desktop/v2): toast on silent launch-dispatch failures Seven silent swallow points across the launch path now surface a toast so the user knows why the agent didn't auto-launch instead of seeing "nothing happened": - dispatchForkLaunch: buildForkAgentLaunch throw -> "Couldn't prepare agent launch" (description = error message). - dispatchForkLaunch: buildForkAgentLaunch returned null AND user gave meaningful input -> warning "Workspace created but no agent launched" with hint to enable one in settings. Silent for the "fresh empty workspace, no agent configured" case (expected). - dispatchForkLaunch: host-service URL not resolved -> "Couldn't reach host service". - dispatchForkLaunch: writeAttachmentsToWorktree throw -> warning "Attachments didn't save to the workspace; agent will launch without files". - writeAttachmentsToWorktree: missing worktreePath -> throw instead of silent return so the outer catch's toast fires. - consumeTerminalLaunch: defensive bail -> "Couldn't open agent pane" (shouldn't happen, but defensive). - consumeTerminalLaunch: ensureSession throw -> "Couldn't start agent terminal" with error message. - pending page: loadAttachments throw in fork intent -> warning "Couldn't load saved attachments" (non-fatal, workspace still creates). All keep their [v2-launch] console.warn/log so trace survives alongside the toast. --- .../pending/$pendingId/dispatchForkLaunch.ts | 30 +++++++++++++++++-- .../_dashboard/pending/$pendingId/page.tsx | 9 ++++-- .../useConsumePendingLaunch.ts | 8 +++++ 3 files changed, 43 insertions(+), 4 deletions(-) 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 index 92940969ff1..38533675bcf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -1,3 +1,4 @@ +import { toast } from "@superset/ui/sonner"; import { env } from "renderer/env.renderer"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import type { @@ -59,7 +60,9 @@ export async function dispatchForkLaunch({ agentConfigs, }); } 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; } @@ -78,7 +81,23 @@ export async function dispatchForkLaunch({ }); if (!build) { - console.warn("[v2-launch] dispatchForkLaunch: buildForkAgentLaunch returned null — no launch"); + 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; } @@ -91,6 +110,9 @@ export async function dispatchForkLaunch({ const hostUrl = resolveHostUrl(pending.hostTarget, activeHostUrl); 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; } @@ -103,7 +125,11 @@ export async function dispatchForkLaunch({ }); } } 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 } @@ -143,7 +169,7 @@ async function writeAttachmentsToWorktree({ console.warn( "[v2-launch] workspace has no worktreePath; skipping attachments", ); - return; + throw new Error("Workspace has no worktreePath"); } const dir = joinPath(worktreePath, ".superset/attachments"); 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 d21dfd4b8f2..70235f66a34 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"; @@ -79,8 +80,12 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { if (pending.attachmentCount > 0) { try { loadedAttachments = await loadAttachments(pendingId); - } catch { - // proceed without + } 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( 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 index db037843f28..f70c12c7770 100644 --- 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 @@ -1,4 +1,5 @@ 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"; @@ -129,6 +130,11 @@ async function consumeTerminalLaunch({ 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; } @@ -146,7 +152,9 @@ async function consumeTerminalLaunch({ 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; } From 5dd5d704c5c5f1c3bae582f9d03b3725b0c07ad5 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 17:40:40 -0700 Subject: [PATCH 31/42] lint --- .../useConsumePendingLaunch/useConsumePendingLaunch.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index f70c12c7770..4cefc4dcafa 100644 --- 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 @@ -133,7 +133,8 @@ async function consumeTerminalLaunch({ // 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.", + description: + "Missing launch data — please retry from the workspace menu.", }); return; } @@ -153,7 +154,10 @@ async function consumeTerminalLaunch({ }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.warn("[v2-launch] consumeTerminalLaunch: ensureSession failed:", err); + console.warn( + "[v2-launch] consumeTerminalLaunch: ensureSession failed:", + err, + ); toast.error("Couldn't start agent terminal", { description: msg }); return; } From 9036362c2f8f3d1a46c722abee38aacaddfe91e3 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 17:48:49 -0700 Subject: [PATCH 32/42] =?UTF-8?q?fix(desktop):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20real=20issues=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the non-stale, non-debatable feedback from review bots: - Prototype-chain substitution in prompt templates (agent-prompt- template.ts + buildLaunchSpec.ts): {{toString}} and similar now stay intact. Use Object.hasOwn() instead of `variables[key] ??`. - renderTaskPromptTemplate no longer picks up generic 3+-newline collapsing — task-flow output matches V1 exactly: own-property substitution + trim only. - buildLaunchSpec.renderUserTemplate tolerates whitespace in the placeholder: {{ userPrompt }} / {{userPrompt}} / {{ userPrompt }} all match. - Pending page's fork dispatch fetches agent configs imperatively via trpcUtils.settings.getAgentPresets.fetch() instead of reading from a useQuery hook — eliminates the race where a not-yet- resolved query silently skipped the dispatch and lost the launch for a successful workspace create. - Drop ContextSection.scope field. It was never read (buildLaunchSpec ignored it); no contributor populated anything but "user" after we removed agent-instructions. Cleaner type + future re-introduction when a real system-scope consumer lands (phase 6 host-side instructions injection). Tests: 54 context-suite passing, 14 shared-suite passing; desktop typecheck clean in touched areas. --- .../_dashboard/pending/$pendingId/page.tsx | 18 +++++++------ .../launchContext.multi-source.ts | 7 ----- .../__fixtures__/launchContext.prompt-only.ts | 1 - .../shared/context/buildLaunchSpec.test.ts | 15 ----------- .../src/shared/context/buildLaunchSpec.ts | 12 +++++---- .../src/shared/context/composer.test.ts | 7 ----- .../context/contributors/attachment.test.ts | 1 - .../shared/context/contributors/attachment.ts | 1 - .../context/contributors/githubIssue.test.ts | 1 - .../context/contributors/githubIssue.ts | 1 - .../context/contributors/githubPr.test.ts | 1 - .../shared/context/contributors/githubPr.ts | 1 - .../context/contributors/internalTask.test.ts | 1 - .../context/contributors/internalTask.ts | 1 - .../context/contributors/userPrompt.test.ts | 1 - .../shared/context/contributors/userPrompt.ts | 1 - apps/desktop/src/shared/context/types.ts | 5 ---- packages/shared/src/agent-prompt-template.ts | 27 ++++++++++++++----- 18 files changed, 38 insertions(+), 64 deletions(-) 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 70235f66a34..d7f94c1f1e8 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 @@ -54,7 +54,7 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { const createWorkspace = useCreateDashboardWorkspace(); const checkoutWorkspace = useCheckoutDashboardWorkspace(); const adoptWorktree = useAdoptWorktree(); - const agentPresetsQuery = electronTrpc.settings.getAgentPresets.useQuery(); + const trpcUtils = electronTrpc.useUtils(); const { activeHostUrl } = useLocalHostService(); return useCallback(async () => { @@ -109,16 +109,18 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { // 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. - if ( - pending.intent === "fork" && - result.workspace?.id && - agentPresetsQuery.data - ) { + // + // 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: agentPresetsQuery.data, + agentConfigs, activeHostUrl, onApplyToRow: (patch) => { collections.pendingWorkspaces.update(pendingId, (draft) => { @@ -154,7 +156,7 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { adoptWorktree, pending, pendingId, - agentPresetsQuery.data, + trpcUtils, activeHostUrl, ]); } diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts index cc85b70d5a0..13106bdd6d9 100644 --- a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -30,14 +30,12 @@ export const launchContextMultiSource: LaunchContext = { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "refactor the auth middleware" }], }, { id: `task:${internalTaskRefactorAuth.id}`, kind: "internal-task", - scope: "user", label: `Task ${internalTaskRefactorAuth.id} — ${internalTaskRefactorAuth.title}`, content: [ { @@ -50,7 +48,6 @@ export const launchContextMultiSource: LaunchContext = { { id: `issue:${githubIssueAuthMiddleware.number}`, kind: "github-issue", - scope: "user", label: `Issue #${githubIssueAuthMiddleware.number} — ${githubIssueAuthMiddleware.title}`, content: [ { @@ -66,7 +63,6 @@ export const launchContextMultiSource: LaunchContext = { { id: `issue:${githubIssueTokenRotation.number}`, kind: "github-issue", - scope: "user", label: `Issue #${githubIssueTokenRotation.number} — ${githubIssueTokenRotation.title}`, content: [ { @@ -82,7 +78,6 @@ export const launchContextMultiSource: LaunchContext = { { id: `pr:${githubPrAuthRewrite.number}`, kind: "github-pr", - scope: "user", label: `PR #${githubPrAuthRewrite.number} — ${githubPrAuthRewrite.title}`, content: [ { @@ -95,7 +90,6 @@ export const launchContextMultiSource: LaunchContext = { { id: "attachment:logs.txt", kind: "attachment", - scope: "user", label: "logs.txt", content: [ { @@ -109,7 +103,6 @@ export const launchContextMultiSource: LaunchContext = { { id: "attachment:screenshot.png", kind: "attachment", - scope: "user", label: "screenshot.png", content: [ { diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts index 5053fa6c343..c8b6ef4d0af 100644 --- a/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts @@ -14,7 +14,6 @@ export const launchContextPromptOnly: LaunchContext = { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "refactor the auth middleware" }], }, diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts index 8dc4a97a17e..fa4ebf2bbc9 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -52,7 +52,6 @@ describe("buildLaunchSpec", () => { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "hello" }], }, @@ -68,7 +67,6 @@ describe("buildLaunchSpec", () => { const section = { id: "user-prompt", kind: "user-prompt" as const, - scope: "user" as const, label: "Prompt", content: [ { type: "text" as const, text: "refactor the auth middleware" }, @@ -98,7 +96,6 @@ describe("buildLaunchSpec", () => { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "hi" }], }, @@ -116,14 +113,12 @@ describe("buildLaunchSpec", () => { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "refactor" }], }, { id: "issue:123", kind: "github-issue", - scope: "user", label: "Issue #123 — Auth", content: [ { @@ -149,21 +144,18 @@ describe("buildLaunchSpec", () => { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "plan" }], }, { id: "task:T-1", kind: "internal-task", - scope: "user", label: "Task T-1", content: [{ type: "text", text: "# T-1\n\nOne." }], }, { id: "task:T-2", kind: "internal-task", - scope: "user", label: "Task T-2", content: [{ type: "text", text: "# T-2\n\nTwo." }], }, @@ -184,14 +176,12 @@ describe("buildLaunchSpec", () => { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "fix the bug" }], }, { id: "attachment:logs.txt", kind: "attachment", - scope: "user", label: "logs.txt", content: [ { @@ -205,7 +195,6 @@ describe("buildLaunchSpec", () => { { id: "attachment:screen.png", kind: "attachment", - scope: "user", label: "screen.png", content: [ { type: "image", data: PNG_BYTES, mediaType: "image/png" }, @@ -230,7 +219,6 @@ describe("buildLaunchSpec", () => { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [ { type: "text", text: "see this:" }, @@ -269,7 +257,6 @@ describe("buildLaunchSpec", () => { { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [ { type: "text", text: "check this log:" }, @@ -300,7 +287,6 @@ describe("buildLaunchSpec", () => { { id: "issue:1", kind: "github-issue", - scope: "user", label: "Issue #1", content: [{ type: "text", text: "# Issue\n\nbody" }], }, @@ -429,7 +415,6 @@ Replaces plaintext token storage with encrypted KV. { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "hi" }], }, diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.ts b/apps/desktop/src/shared/context/buildLaunchSpec.ts index a44583cf8fb..b34410199e4 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.ts @@ -8,7 +8,7 @@ import type { LaunchSourceKind, } from "./types"; -const USER_PROMPT_PLACEHOLDER = "{{userPrompt}}"; +const USER_PROMPT_PLACEHOLDER_RE = /\{\{\s*userPrompt\s*\}\}/; const PLACEHOLDER_RE = /\{\{\s*([^}]+?)\s*\}\}/g; /** @@ -77,13 +77,15 @@ function renderUserTemplate( userPromptParts: ContentPart[], nonUserVariables: Record<string, string>, ): ContentPart[] { - const splitIndex = template.indexOf(USER_PROMPT_PLACEHOLDER); + // 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 + splitIndex === -1 || !match ? ["", template] : [ template.slice(0, splitIndex), - template.slice(splitIndex + USER_PROMPT_PLACEHOLDER.length), + template.slice(splitIndex + match[0].length), ]; const beforeText = substituteVariables(beforeRaw, nonUserVariables); @@ -114,7 +116,7 @@ function substituteVariables( ): string { return template.replace(PLACEHOLDER_RE, (match, rawKey: string) => { const key = rawKey.trim(); - return variables[key] ?? match; + return Object.hasOwn(variables, key) ? variables[key] : match; }); } diff --git a/apps/desktop/src/shared/context/composer.test.ts b/apps/desktop/src/shared/context/composer.test.ts index bd4a4d14237..7184fbc4799 100644 --- a/apps/desktop/src/shared/context/composer.test.ts +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -34,14 +34,12 @@ function registry( "user-prompt": makeContributor("user-prompt", async (s) => ({ id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: s.content, })), "github-issue": makeContributor("github-issue", async (s) => ({ id: `issue:${s.url}`, kind: "github-issue", - scope: "user", label: s.url, content: [{ type: "text", text: s.url }], meta: { url: s.url, taskSlug: `slug-${s.url}` }, @@ -49,7 +47,6 @@ function registry( "github-pr": makeContributor("github-pr", async (s) => ({ id: `pr:${s.url}`, kind: "github-pr", - scope: "user", label: s.url, content: [{ type: "text", text: s.url }], meta: { url: s.url }, @@ -57,7 +54,6 @@ function registry( "internal-task": makeContributor("internal-task", async (s) => ({ id: `task:${s.id}`, kind: "internal-task", - scope: "user", label: s.id, content: [{ type: "text", text: s.id }], meta: { taskSlug: `task-slug-${s.id}` }, @@ -65,7 +61,6 @@ function registry( attachment: makeContributor("attachment", async (s) => ({ id: `attachment:${s.file.filename ?? "unnamed"}`, kind: "attachment", - scope: "user", label: s.file.filename ?? "attachment", content: [ { @@ -124,7 +119,6 @@ describe("buildLaunchContext", () => { return { id: `issue:${s.url}`, kind: "github-issue", - scope: "user", label: s.url, content: [{ type: "text", text: s.url }], }; @@ -249,7 +243,6 @@ describe("buildLaunchContext", () => { return { id: `issue:${s.url}`, kind: "github-issue", - scope: "user", label: s.url, content: [{ type: "text", text: s.url }], }; diff --git a/apps/desktop/src/shared/context/contributors/attachment.test.ts b/apps/desktop/src/shared/context/contributors/attachment.test.ts index 23f408daecb..8ae55713420 100644 --- a/apps/desktop/src/shared/context/contributors/attachment.test.ts +++ b/apps/desktop/src/shared/context/contributors/attachment.test.ts @@ -23,7 +23,6 @@ describe("attachmentContributor", () => { resolveCtx, ); expect(section?.kind).toBe("attachment"); - expect(section?.scope).toBe("user"); expect(section?.label).toBe("notes.txt"); expect(section?.content).toEqual([ { diff --git a/apps/desktop/src/shared/context/contributors/attachment.ts b/apps/desktop/src/shared/context/contributors/attachment.ts index 2e36636428d..ef820cf6e93 100644 --- a/apps/desktop/src/shared/context/contributors/attachment.ts +++ b/apps/desktop/src/shared/context/contributors/attachment.ts @@ -22,7 +22,6 @@ export const attachmentContributor: ContextContributor<{ return { id: `attachment:${file.filename ?? "unnamed"}`, kind: "attachment", - scope: "user", 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 index de6f50be920..64e0328610b 100644 --- a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts @@ -40,7 +40,6 @@ describe("githubIssueContributor", () => { expect(section).toEqual({ id: `issue:${ISSUE.number}`, kind: "github-issue", - scope: "user", label: `Issue #${ISSUE.number} — ${ISSUE.title}`, content: [ { diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.ts b/apps/desktop/src/shared/context/contributors/githubIssue.ts index 88f86737232..3673bb214f5 100644 --- a/apps/desktop/src/shared/context/contributors/githubIssue.ts +++ b/apps/desktop/src/shared/context/contributors/githubIssue.ts @@ -31,7 +31,6 @@ export const githubIssueContributor: ContextContributor<{ return { id: `issue:${issue.number}`, kind: "github-issue", - scope: "user", 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 index 076146d4ec2..d1877070218 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -40,7 +40,6 @@ describe("githubPrContributor", () => { expect(section).toEqual({ id: `pr:${PR.number}`, kind: "github-pr", - scope: "user", label: `PR #${PR.number} — ${PR.title}`, content: [ { diff --git a/apps/desktop/src/shared/context/contributors/githubPr.ts b/apps/desktop/src/shared/context/contributors/githubPr.ts index 4fb564d2bd9..d709785037e 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.ts @@ -32,7 +32,6 @@ export const githubPrContributor: ContextContributor<{ return { id: `pr:${pr.number}`, kind: "github-pr", - scope: "user", label: `PR #${pr.number} — ${pr.title}`, content: [{ type: "text", text }], meta: { url: pr.url }, diff --git a/apps/desktop/src/shared/context/contributors/internalTask.test.ts b/apps/desktop/src/shared/context/contributors/internalTask.test.ts index baea7f39c42..419a8418d2c 100644 --- a/apps/desktop/src/shared/context/contributors/internalTask.test.ts +++ b/apps/desktop/src/shared/context/contributors/internalTask.test.ts @@ -39,7 +39,6 @@ describe("internalTaskContributor", () => { expect(section).toEqual({ id: `task:${TASK.id}`, kind: "internal-task", - scope: "user", label: `Task ${TASK.id} — ${TASK.title}`, content: [ { diff --git a/apps/desktop/src/shared/context/contributors/internalTask.ts b/apps/desktop/src/shared/context/contributors/internalTask.ts index 904fb2cee37..108802d34a9 100644 --- a/apps/desktop/src/shared/context/contributors/internalTask.ts +++ b/apps/desktop/src/shared/context/contributors/internalTask.ts @@ -33,7 +33,6 @@ export const internalTaskContributor: ContextContributor<{ return { id: `task:${task.id}`, kind: "internal-task", - scope: "user", 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 index 784ef241e0f..b75c9dd1288 100644 --- a/apps/desktop/src/shared/context/contributors/userPrompt.test.ts +++ b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts @@ -23,7 +23,6 @@ describe("userPromptContributor", () => { expect(section).toEqual({ id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content: [{ type: "text", text: "refactor the auth middleware" }], }); diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.ts b/apps/desktop/src/shared/context/contributors/userPrompt.ts index 1f7ef11315b..3234ea7615e 100644 --- a/apps/desktop/src/shared/context/contributors/userPrompt.ts +++ b/apps/desktop/src/shared/context/contributors/userPrompt.ts @@ -57,7 +57,6 @@ export const userPromptContributor: ContextContributor<{ return { id: "user-prompt", kind: "user-prompt", - scope: "user", label: "Prompt", content, }; diff --git a/apps/desktop/src/shared/context/types.ts b/apps/desktop/src/shared/context/types.ts index 3bc366199a0..49a3a9b9bf6 100644 --- a/apps/desktop/src/shared/context/types.ts +++ b/apps/desktop/src/shared/context/types.ts @@ -45,17 +45,12 @@ export type ContentPart = /** * A resolved contribution from a single source. Every contributor * produces one of these (or null on non-fatal failure). - * - * `scope` decides whether this lands in the cacheable system portion of - * the final launch spec or in the per-launch user portion. */ export interface ContextSection { id: string; // stable, e.g. "issue:123" kind: LaunchSourceKind; - scope: "system" | "user"; label: string; content: ContentPart[]; - cacheControl?: "ephemeral"; meta?: { taskSlug?: string; url?: string; diff --git a/packages/shared/src/agent-prompt-template.ts b/packages/shared/src/agent-prompt-template.ts index 643ba906b61..e280e15b0f6 100644 --- a/packages/shared/src/agent-prompt-template.ts +++ b/packages/shared/src/agent-prompt-template.ts @@ -18,15 +18,26 @@ export function renderPromptTemplate( template: string, variables: Record<string, string>, ): string { - return template - .replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, rawKey: string) => { - const key = rawKey.trim(); - return variables[key] ?? match; - }) + 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(); + return Object.hasOwn(variables, key) ? variables[key] : match; + }); +} + // --------------------------------------------------------------------------- // Task prompt variables (unchanged from v1 — used by the task-run flow) // --------------------------------------------------------------------------- @@ -79,12 +90,16 @@ 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 { - return renderPromptTemplate(template, getTaskPromptVariables(task)); + return substituteOwnProperties(template, getTaskPromptVariables(task)).trim(); } export function getSupportedTaskPromptVariables(): AgentTaskPromptVariable[] { From 5974ae34a1f0859e02d9f126c951f2ea49372f23 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 17:53:29 -0700 Subject: [PATCH 33/42] docs(desktop): capture body-fetching gaps observed in manual test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude currently sees title-only for linked issues / PRs / tasks — no bodies. Documents the gap, what V1 did (Electron IPC to projects.getIssueContent), why we can't reuse it for V2 (no Electron in V2 rule), and proposes the host-service procedures + stub swap. Also covers: - Empty `Branch:` in PR block — pending-row schema doesn't carry branch; fix via getPullRequestContent body fetch. - Sanitization helpers to extract from V1 into a shared util. - Attach-as-file vs inline-in-prompt decision (V1 attached, current V2 inlines — keeping inline for phase 1). Ordered work plan at the bottom: getIssueContent first, then PR, then internal-task (requires scoping). Acceptance criteria shows the expected prompt shape after the fixes land. --- apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md | 196 ++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md 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..b9ee51b090c --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md @@ -0,0 +1,196 @@ +# V2 Launch Context — Body-Fetching Gaps + +Companion to `V2_LAUNCH_CONTEXT.md`. Tracks the remaining work to make +linked issues / PRs / tasks actually useful to the agent. + +## Current state (manual-test observation 2026-04-15) + +Claude receives titles only — no issue bodies, no PR descriptions, no +task specs. The prompt looks like: + +``` +<user prompt> +# <task title> +# <issue title> +# <PR title> +Branch: `` +- .superset/attachments/<file> +``` + +Per section: + +- **User prompt** — ✓ works. +- **Attachment files** — ✓ bytes written to worktree, path refs inline. +- **Linked issues** — ✗ title only, body empty. +- **Linked PRs** — ✗ title only, body empty, branch empty. +- **Linked internal tasks** — ✗ title only, description empty. + +## Why — the resolver stubs + +`buildForkAgentLaunch.ts` → `buildResolveCtxFromPending` fakes the +three fetchers by reading **only** what the pending row already carries +(`title`, `url`, `number`, `slug`). The pending row is populated at +modal-submit time from linked-issue picker results, which also only +carry metadata. No network fetch happens anywhere in the V2 path. + +## How V1 did it (issues only) + +`apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx:834-944`. + +1. Electron-IPC call `utils.client.projects.getIssueContent.query({ projectId, issueNumber })`. +2. Returns full `{ number, title, body, url, state, author, createdAt, updatedAt }`. +3. HTML-entity sanitize + URL protocol validation. +4. 50 KB body truncation. +5. Formatted as markdown; encoded as base64 data URL. +6. Attached as a **file** named `github-issue-<n>.md` (not inlined in + the prompt text). + +V1 did **not** fetch PR bodies or task descriptions — same gap as V2 +for those kinds. + +## Constraints for V2 + +- **No Electron IPC.** `electronTrpc.projects.getIssueContent` is off- + limits. We talk to host-service over HTTP via + `getHostServiceClientByUrl(hostUrl)` in the pending page / + dispatchForkLaunch. +- **Same sanitization rules apply.** HTML entities, URL protocols, + 50 KB truncation, sanitize author strings. +- **Same output shape options.** Either attach as a + `github-issue-<n>.md` file (V1 parity) or inline the body into the + prompt via the existing `{{issues}}` / `{{prs}}` / `{{tasks}}` + template variables. + +## Proposed fixes + +### 1. Add host-service body-fetch procedures + +Host-service already has `searchGitHubIssues` + `searchPullRequests` +returning metadata only. Extend to include body endpoints: + +- `workspaceCreation.getIssueContent({ projectId, issueNumber })` + → `{ number, title, body, url, state, author, createdAt, updatedAt }`. + Thin wrapper over `octokit.issues.get(...)`. Mirror V1's response shape. +- `workspaceCreation.getPullRequestContent({ projectId, prNumber })` + → `{ number, title, body, url, state, author, createdAt, updatedAt, branch, headSha, baseBranch, isDraft }`. + Wraps `octokit.pulls.get(...)`. Includes `branch` which V2 doesn't + currently have. +- `workspaceCreation.getInternalTaskContent({ projectId, taskId })` + → `{ id, slug, title, description, acceptanceCriteria?, status, labels }`. + Uses whatever internal task API the app already talks to (likely via + `apiTrpcClient`, not host-service). If the task source lives on the + Superset API server rather than host-service, wire it through there + instead — host-service shouldn't need to re-proxy. + +### 2. Replace the stubs in `buildForkAgentLaunch.ts` + +`buildResolveCtxFromPending` currently returns empty bodies. Swap +its three fetchers for real calls: + +```ts +fetchIssue: async (url) => { + // parse number out of the url (we have the pending row's number too) + const { data } = await client.workspaceCreation.getIssueContent.query({ + projectId, + issueNumber, + }); + return { + number: data.number, + url: data.url, + title: data.title, + body: sanitizeAndTruncate(data.body, 50_000), + slug: data.slug ?? slugify(data.title), + }; +}, +``` + +Same shape for PR and task. Errors → return current pending-row fallback +so the launch degrades to title-only instead of failing outright. + +### 3. Decide — attach as file vs inline in prompt + +V1 attaches as `github-issue-<n>.md`; our V2 pipeline currently inlines +via the `{{issues}}` / `{{prs}}` / `{{tasks}}` template variables. + +| | Inline (current V2) | Attach as file (V1) | +|---|---|---| +| Prompt length | Grows with bodies | Stays small | +| Agent discovery | Automatic (in prompt) | Agent must read file | +| Token caching | Harder to hit | Better: file path is stable | +| File list on disk | Only real attachments | Every linked thing | +| User clarity | See content in prompt | Just a file ref | + +Recommendation: **inline for phase 1, add attach-as-file for phase 2** +(as a user setting or agent-config flag). Inline is simpler, matches +the current template structure, and makes body-fetching immediately +useful. + +### 4. Fix the pending-row schema for PR branch + +`PendingLinkedPR` schema in the dashboard-sidebar schema has no +`branch` field. Host-service's `searchPullRequests` doesn't return it +either — current consumers don't need it. Either: +- Add `branch` to the pending-row schema + populate at link-add time + from a second PR-detail API call. +- Or derive it at dispatch time by calling + `getPullRequestContent` (now we'd fetch for a different reason too). + +The second option is less work — we already need the body fetch. + +### 5. Sanitization helpers + +Port V1's `sanitizeText` / `sanitizeUrl` / 50-KB-truncate into a shared +util and use from the dispatch path. Don't reinvent per-contributor. + +Probably lives at `packages/shared/src/text-sanitize.ts` (used by host- +service + renderer). + +## Order of work + +1. **Host-service `getIssueContent` procedure** — smallest, + most-visible win. Drop-in for existing `searchGitHubIssues` auth + path. Ship that, wire the stub replacement, and body content starts + flowing. +2. **Host-service `getPullRequestContent`** — same pattern. Unblocks + the empty `Branch:` line. +3. **Internal-task body source** — depends on where tasks live + (Superset API? separate service?). Scope out before committing to a + shape. +4. **Sanitization shared util** — required for #1 and #2 but can land + with whichever ships first. +5. **Attach-as-file mode** — optional, user/agent-config setting. + Deferred to phase 2. + +## Acceptance criteria + +After this work, a multi-source launch (prompt + task + 2 issues + +PR + attachment) should render a prompt like: + +``` +<user prompt> + +# Task <id> — <title> +<description> + +# Issue #<n> — <title> +**URL:** ... **State:** open **Author:** ... +<body, up to 50 KB> + +# Issue #<m> — <title> +... + +# PR #<p> — <title> +**Branch:** `feature/xyz` **Base:** main +<body> + +- .superset/attachments/<file> +``` + +No placeholder empty `Branch: ` lines. No naked titles without bodies. + +## Out of scope + +- Comments on issues/PRs (V1 didn't fetch them either). +- Diff content for linked PRs (agent can `gh pr diff` itself). +- Cross-repo linked PRs (already handled by + `normalizeGitHubQuery.repoMismatch` at search time). From 4122e0a866613359aacb48e3491d6315d990e0ce Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 19:02:43 -0700 Subject: [PATCH 34/42] Update PR notes --- .../shared/context/buildLaunchSpec.test.ts | 14 +++++++++++++ .../src/shared/context/buildLaunchSpec.ts | 20 +++++++++++++++---- .../context/contributors/githubPr.test.ts | 19 +++++++----------- .../shared/context/contributors/githubPr.ts | 12 ++++++++++- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts index fa4ebf2bbc9..ad514845836 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -345,6 +345,13 @@ 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" , @@ -388,6 +395,13 @@ 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" , diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.ts b/apps/desktop/src/shared/context/buildLaunchSpec.ts index b34410199e4..95762884dcd 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.ts +++ b/apps/desktop/src/shared/context/buildLaunchSpec.ts @@ -182,9 +182,11 @@ function renderKindBlock(sections: ContextSection[]): string { } /** - * Attachments list 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. + * 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[] = []; @@ -198,7 +200,17 @@ function renderAttachmentsList(sections: ContextSection[]): string { refs.push(`- .superset/attachments/${label ?? "inline-attachment"}`); } } - return refs.length === 0 ? "" : refs.join("\n"); + 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[] { diff --git a/apps/desktop/src/shared/context/contributors/githubPr.test.ts b/apps/desktop/src/shared/context/contributors/githubPr.test.ts index d1877070218..00bbbd35120 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -37,18 +37,13 @@ describe("githubPrContributor", () => { { kind: "github-pr", url: PR.url }, makeCtx(async () => PR), ); - expect(section).toEqual({ - id: `pr:${PR.number}`, - kind: "github-pr", - label: `PR #${PR.number} — ${PR.title}`, - content: [ - { - type: "text", - text: `# ${PR.title}\n\nBranch: \`${PR.branch}\`\n\n${PR.body}`, - }, - ], - meta: { url: PR.url }, - }); + 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(`Branch \`${PR.branch}\` is checked out`); + expect(text).toContain(PR.body); }); test("returns null on 404", async () => { diff --git a/apps/desktop/src/shared/context/contributors/githubPr.ts b/apps/desktop/src/shared/context/contributors/githubPr.ts index d709785037e..b99c8a384fb 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.ts @@ -27,7 +27,17 @@ export const githubPrContributor: ContextContributor<{ } const body = pr.body.trim(); - const header = `# ${pr.title}\n\nBranch: \`${pr.branch}\``; + // 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 + ? `Branch \`${pr.branch}\` is checked out in this workspace — commits you make continue 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}`, From de6dffadd5142b93c52310339727f7a06de0b31e Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 19:03:00 -0700 Subject: [PATCH 35/42] lint --- .../src/shared/context/contributors/githubPr.test.ts | 7 ++++--- packages/shared/src/agent-prompt-template.ts | 11 +++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/shared/context/contributors/githubPr.test.ts b/apps/desktop/src/shared/context/contributors/githubPr.test.ts index 00bbbd35120..1084b8758d3 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -61,8 +61,9 @@ describe("githubPrContributor", () => { { kind: "github-pr", url: PR.url }, makeCtx(async () => ({ ...PR, body: "" })), ); - expect(section?.content).toEqual([ - { type: "text", text: `# ${PR.title}\n\nBranch: \`${PR.branch}\`` }, - ]); + 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/packages/shared/src/agent-prompt-template.ts b/packages/shared/src/agent-prompt-template.ts index e280e15b0f6..5234166110c 100644 --- a/packages/shared/src/agent-prompt-template.ts +++ b/packages/shared/src/agent-prompt-template.ts @@ -32,10 +32,13 @@ function substituteOwnProperties( template: string, variables: Record<string, string>, ): string { - return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, rawKey: string) => { - const key = rawKey.trim(); - return Object.hasOwn(variables, key) ? variables[key] : match; - }); + return template.replace( + /\{\{\s*([^}]+?)\s*\}\}/g, + (match, rawKey: string) => { + const key = rawKey.trim(); + return Object.hasOwn(variables, key) ? variables[key] : match; + }, + ); } // --------------------------------------------------------------------------- From 83b4915c1f564a2aa83b9ba61f7bd22b82e316a3 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 19:03:05 -0700 Subject: [PATCH 36/42] feat(desktop/v2): attachment framing + PR checkout hint + gap doc rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt refinements from manual testing: - buildLaunchSpec {{attachments}} block now includes a short framing header: "Attached files — read them to understand the request." Cues the agent to actually use the files rather than treating them as passive metadata. Only appears when there are files. - githubPr contributor says "Branch `X` is checked out in this workspace — commits you make continue this PR." Confirms to the agent that the worktree is on the PR's branch, so it shouldn't create a new branch or open a new PR. - V2_LAUNCH_CONTEXT_GAPS.md rewritten with locked design decisions: bodies inline in prompt (no file writes for linked context), no truncation, no sanitization, PR checkout is true. Work plan: host-service getIssueContent → getPullRequestContent → task body API → swap stubs. Target prompt shape included. 54 tests green; 2 snapshots updated for new PR format. --- apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md | 280 ++++++++++---------- 1 file changed, 143 insertions(+), 137 deletions(-) diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md index b9ee51b090c..dda5cc097e2 100644 --- a/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md @@ -1,196 +1,202 @@ # V2 Launch Context — Body-Fetching Gaps -Companion to `V2_LAUNCH_CONTEXT.md`. Tracks the remaining work to make -linked issues / PRs / tasks actually useful to the agent. +Companion to `V2_LAUNCH_CONTEXT.md`. Tracks remaining work to make +linked issues / PRs / tasks useful to the agent. -## Current state (manual-test observation 2026-04-15) +## Current state (2026-04-15) -Claude receives titles only — no issue bodies, no PR descriptions, no -task specs. The prompt looks like: +Claude receives titles only — no bodies: ``` <user prompt> + # <task title> + # <issue title> -# <PR title> -Branch: `` + +# PR #<n> — <pr title> +Branch `<branch>` is checked out in this workspace — commits you make continue this PR. + +# Attached files +... - .superset/attachments/<file> ``` -Per section: +Bodies are empty because `buildResolveCtxFromPending` stubs return +empty strings. The pipeline otherwise works end-to-end. -- **User prompt** — ✓ works. -- **Attachment files** — ✓ bytes written to worktree, path refs inline. -- **Linked issues** — ✗ title only, body empty. -- **Linked PRs** — ✗ title only, body empty, branch empty. -- **Linked internal tasks** — ✗ title only, description empty. +## Design decisions (locked) -## Why — the resolver stubs +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. -`buildForkAgentLaunch.ts` → `buildResolveCtxFromPending` fakes the -three fetchers by reading **only** what the pending row already carries -(`title`, `url`, `number`, `slug`). The pending row is populated at -modal-submit time from linked-issue picker results, which also only -carry metadata. No network fetch happens anywhere in the V2 path. +## Work plan -## How V1 did it (issues only) +### 1. Host-service `getIssueContent` -`apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx:834-944`. +Add to `workspaceCreation` router (same GitHub auth path as +`searchGitHubIssues`): -1. Electron-IPC call `utils.client.projects.getIssueContent.query({ projectId, issueNumber })`. -2. Returns full `{ number, title, body, url, state, author, createdAt, updatedAt }`. -3. HTML-entity sanitize + URL protocol validation. -4. 50 KB body truncation. -5. Formatted as markdown; encoded as base64 data URL. -6. Attached as a **file** named `github-issue-<n>.md` (not inlined in - the prompt text). +```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, + }; + }), +``` -V1 did **not** fetch PR bodies or task descriptions — same gap as V2 -for those kinds. +### 2. Host-service `getPullRequestContent` -## Constraints for V2 +Same router, wraps `octokit.pulls.get`: -- **No Electron IPC.** `electronTrpc.projects.getIssueContent` is off- - limits. We talk to host-service over HTTP via - `getHostServiceClientByUrl(hostUrl)` in the pending page / - dispatchForkLaunch. -- **Same sanitization rules apply.** HTML entities, URL protocols, - 50 KB truncation, sanitize author strings. -- **Same output shape options.** Either attach as a - `github-issue-<n>.md` file (V1 parity) or inline the body into the - prompt via the existing `{{issues}}` / `{{prs}}` / `{{tasks}}` - template variables. +```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, + }; + }), +``` -## Proposed fixes +### 3. Internal-task body source -### 1. Add host-service body-fetch procedures +Find the API for task details. V1 uses Electron IPC; V2 has +collections in the task view (live-query from cloud). Options: -Host-service already has `searchGitHubIssues` + `searchPullRequests` -returning metadata only. Extend to include body endpoints: +- `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. -- `workspaceCreation.getIssueContent({ projectId, issueNumber })` - → `{ number, title, body, url, state, author, createdAt, updatedAt }`. - Thin wrapper over `octokit.issues.get(...)`. Mirror V1's response shape. -- `workspaceCreation.getPullRequestContent({ projectId, prNumber })` - → `{ number, title, body, url, state, author, createdAt, updatedAt, branch, headSha, baseBranch, isDraft }`. - Wraps `octokit.pulls.get(...)`. Includes `branch` which V2 doesn't - currently have. -- `workspaceCreation.getInternalTaskContent({ projectId, taskId })` - → `{ id, slug, title, description, acceptanceCriteria?, status, labels }`. - Uses whatever internal task API the app already talks to (likely via - `apiTrpcClient`, not host-service). If the task source lives on the - Superset API server rather than host-service, wire it through there - instead — host-service shouldn't need to re-proxy. +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`). -### 2. Replace the stubs in `buildForkAgentLaunch.ts` +### 4. Swap stubs in `buildResolveCtxFromPending` -`buildResolveCtxFromPending` currently returns empty bodies. Swap -its three fetchers for real calls: +`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) => { - // parse number out of the url (we have the pending row's number too) - const { data } = await client.workspaceCreation.getIssueContent.query({ - projectId, - issueNumber, + 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: sanitizeAndTruncate(data.body, 50_000), - slug: data.slug ?? slugify(data.title), + body: data.body, + slug: match.slug, }; }, ``` -Same shape for PR and task. Errors → return current pending-row fallback -so the launch degrades to title-only instead of failing outright. - -### 3. Decide — attach as file vs inline in prompt +Same pattern for PR (using `match.prNumber`) and task (using task API). -V1 attaches as `github-issue-<n>.md`; our V2 pipeline currently inlines -via the `{{issues}}` / `{{prs}}` / `{{tasks}}` template variables. +### 5. Pass `hostUrl` to `buildForkAgentLaunch` -| | Inline (current V2) | Attach as file (V1) | -|---|---|---| -| Prompt length | Grows with bodies | Stays small | -| Agent discovery | Automatic (in prompt) | Agent must read file | -| Token caching | Harder to hit | Better: file path is stable | -| File list on disk | Only real attachments | Every linked thing | -| User clarity | See content in prompt | Just a file ref | +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. -Recommendation: **inline for phase 1, add attach-as-file for phase 2** -(as a user setting or agent-config flag). Inline is simpler, matches -the current template structure, and makes body-fetching immediately -useful. +## Target prompt (after fixes) -### 4. Fix the pending-row schema for PR branch - -`PendingLinkedPR` schema in the dashboard-sidebar schema has no -`branch` field. Host-service's `searchPullRequests` doesn't return it -either — current consumers don't need it. Either: -- Add `branch` to the pending-row schema + populate at link-add time - from a second PR-detail API call. -- Or derive it at dispatch time by calling - `getPullRequestContent` (now we'd fetch for a different reason too). - -The second option is less work — we already need the body fetch. - -### 5. Sanitization helpers +``` +<user prompt> -Port V1's `sanitizeText` / `sanitizeUrl` / 50-KB-truncate into a shared -util and use from the dispatch path. Don't reinvent per-contributor. +# Task TASK-42 — Refactor auth middleware -Probably lives at `packages/shared/src/text-sanitize.ts` (used by host- -service + renderer). +Split session-token storage from request handling so we can encrypt +at rest. Keep the public API shape stable. -## Order of work +Acceptance criteria: +- Sessions encrypted at rest +- No public-API shape change +- Migration for existing sessions -1. **Host-service `getIssueContent` procedure** — smallest, - most-visible win. Drop-in for existing `searchGitHubIssues` auth - path. Ship that, wire the stub replacement, and body content starts - flowing. -2. **Host-service `getPullRequestContent`** — same pattern. Unblocks - the empty `Branch:` line. -3. **Internal-task body source** — depends on where tasks live - (Superset API? separate service?). Scope out before committing to a - shape. -4. **Sanitization shared util** — required for #1 and #2 but can land - with whichever ships first. -5. **Attach-as-file mode** — optional, user/agent-config setting. - Deferred to phase 2. +# Issue #123 — Auth middleware stores tokens in plaintext -## Acceptance criteria +Legal flagged this. Sessions written to disk without encryption. We +need to move to an encrypted KV before the compliance deadline. -After this work, a multi-source launch (prompt + task + 2 issues + -PR + attachment) should render a prompt like: +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... -``` -<user prompt> +# PR #200 — Rewrite auth middleware -# Task <id> — <title> -<description> +Branch `fix/auth-encryption` is checked out in this workspace — +commits you make continue this PR. -# Issue #<n> — <title> -**URL:** ... **State:** open **Author:** ... -<body, up to 50 KB> +Replaces plaintext token storage with encrypted KV. Migrates +existing sessions on first request... -# Issue #<m> — <title> -... +# Attached files -# PR #<p> — <title> -**Branch:** `feature/xyz` **Base:** main -<body> +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/<file> +- .superset/attachments/trace.log +- .superset/attachments/notes.md ``` -No placeholder empty `Branch: ` lines. No naked titles without bodies. +## 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. -## Out of scope +## Deferred -- Comments on issues/PRs (V1 didn't fetch them either). -- Diff content for linked PRs (agent can `gh pr diff` itself). -- Cross-repo linked PRs (already handled by - `normalizeGitHubQuery.repoMismatch` at search time). +- Issue/PR comments (phase 2). +- Body truncation (revisit if agents hit context limits in practice). +- Attach-as-file mode (not needed; inline works). From 897af56b330536ca98e55ee507c0c5181749540b Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 19:08:09 -0700 Subject: [PATCH 37/42] feat(desktop/context): explicit kind labels in contributor headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents shouldn't guess whether a section is a task, issue, or PR from context clues. Each contributor now prefixes its heading with the kind: - `# GitHub Issue #123 — Auth middleware stores tokens in plaintext` - `# Task TASK-42 — Refactor auth middleware` - `# PR #200 — Rewrite auth middleware` PR phrasing also clarified: "This PR is checked out in this workspace on branch `fix/auth-encryption`. Commits you make here will be added to this PR." 54 tests green; 2 snapshots updated. --- .../context/contributors/githubIssue.test.ts | 25 +++++++------------ .../context/contributors/githubIssue.ts | 3 ++- .../context/contributors/githubPr.test.ts | 2 +- .../shared/context/contributors/githubPr.ts | 2 +- .../context/contributors/internalTask.test.ts | 25 +++++++------------ .../context/contributors/internalTask.ts | 5 ++-- 6 files changed, 24 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts index 64e0328610b..e632f202558 100644 --- a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts @@ -32,23 +32,17 @@ describe("githubIssueContributor", () => { expect(githubIssueContributor.requiresQuery).toBe(true); }); - test("resolves to a user-scoped section with title + body + meta", async () => { + 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).toEqual({ - id: `issue:${ISSUE.number}`, - kind: "github-issue", - label: `Issue #${ISSUE.number} — ${ISSUE.title}`, - content: [ - { - type: "text", - text: `# ${ISSUE.title}\n\n${ISSUE.body}`, - }, - ], - meta: { url: ISSUE.url, taskSlug: ISSUE.slug }, - }); + 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 () => { @@ -77,8 +71,7 @@ describe("githubIssueContributor", () => { { kind: "github-issue", url: ISSUE.url }, makeCtx(async () => ({ ...ISSUE, body: "" })), ); - expect(section?.content).toEqual([ - { type: "text", text: `# ${ISSUE.title}` }, - ]); + 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 index 3673bb214f5..3a509c29974 100644 --- a/apps/desktop/src/shared/context/contributors/githubIssue.ts +++ b/apps/desktop/src/shared/context/contributors/githubIssue.ts @@ -27,7 +27,8 @@ export const githubIssueContributor: ContextContributor<{ } const body = issue.body.trim(); - const text = body ? `# ${issue.title}\n\n${body}` : `# ${issue.title}`; + const heading = `# GitHub Issue #${issue.number} — ${issue.title}`; + const text = body ? `${heading}\n\n${body}` : heading; return { id: `issue:${issue.number}`, kind: "github-issue", diff --git a/apps/desktop/src/shared/context/contributors/githubPr.test.ts b/apps/desktop/src/shared/context/contributors/githubPr.test.ts index 1084b8758d3..cd9f2fcf70c 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.test.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -42,7 +42,7 @@ describe("githubPrContributor", () => { 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(`Branch \`${PR.branch}\` is checked out`); + expect(text).toContain(`This PR is checked out`); expect(text).toContain(PR.body); }); diff --git a/apps/desktop/src/shared/context/contributors/githubPr.ts b/apps/desktop/src/shared/context/contributors/githubPr.ts index b99c8a384fb..9e23c8c04e2 100644 --- a/apps/desktop/src/shared/context/contributors/githubPr.ts +++ b/apps/desktop/src/shared/context/contributors/githubPr.ts @@ -32,7 +32,7 @@ export const githubPrContributor: ContextContributor<{ // it doesn't start a new branch or open another PR — commits // here continue this PR's history. const branchLine = pr.branch - ? `Branch \`${pr.branch}\` is checked out in this workspace — commits you make continue this PR.` + ? `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, diff --git a/apps/desktop/src/shared/context/contributors/internalTask.test.ts b/apps/desktop/src/shared/context/contributors/internalTask.test.ts index 419a8418d2c..c11bfa3e80e 100644 --- a/apps/desktop/src/shared/context/contributors/internalTask.test.ts +++ b/apps/desktop/src/shared/context/contributors/internalTask.test.ts @@ -31,23 +31,17 @@ describe("internalTaskContributor", () => { expect(internalTaskContributor.requiresQuery).toBe(true); }); - test("resolves to a user section with title + description + slug meta", async () => { + 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).toEqual({ - id: `task:${TASK.id}`, - kind: "internal-task", - label: `Task ${TASK.id} — ${TASK.title}`, - content: [ - { - type: "text", - text: `# ${TASK.title}\n\n${TASK.description}`, - }, - ], - meta: { taskSlug: TASK.slug }, - }); + 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}`); + expect(text).toContain(TASK.description!); + expect(section?.meta).toEqual({ taskSlug: TASK.slug }); }); test("omits description when null", async () => { @@ -55,9 +49,8 @@ describe("internalTaskContributor", () => { { kind: "internal-task", id: TASK.id }, makeCtx(async () => ({ ...TASK, description: null })), ); - expect(section?.content).toEqual([ - { type: "text", text: `# ${TASK.title}` }, - ]); + 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 () => { diff --git a/apps/desktop/src/shared/context/contributors/internalTask.ts b/apps/desktop/src/shared/context/contributors/internalTask.ts index 108802d34a9..34304592a5a 100644 --- a/apps/desktop/src/shared/context/contributors/internalTask.ts +++ b/apps/desktop/src/shared/context/contributors/internalTask.ts @@ -27,9 +27,8 @@ export const internalTaskContributor: ContextContributor<{ } const description = task.description?.trim() ?? ""; - const text = description - ? `# ${task.title}\n\n${description}` - : `# ${task.title}`; + const heading = `# Task ${task.id} — ${task.title}`; + const text = description ? `${heading}\n\n${description}` : heading; return { id: `task:${task.id}`, kind: "internal-task", From 2d95bfbb2fc4991c7e7ac3c586861f3930708541 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 19:14:41 -0700 Subject: [PATCH 38/42] feat(desktop/v2): fetch issue + PR bodies via host-service, task via cloud API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch prompt now includes full bodies for linked GitHub issues, PRs, and internal tasks instead of title-only stubs. Host-service (packages/host-service): - getGitHubPullRequestContent: new procedure wrapping octokit.pulls.get. Returns body, branch, baseBranch, author, isDraft, timestamps. (getGitHubIssueContent already existed.) Client (apps/desktop pending page): - buildForkAgentLaunch accepts an optional hostServiceClient. When provided, the issue + PR resolvers call getGitHubIssueContent / getGitHubPullRequestContent for full bodies. Falls back to pending-row title-only if the call fails (non-fatal). - Task resolver calls apiTrpcClient.task.byId (Superset cloud API, same source as the task view) for description. Falls back to title-only on failure. - dispatchForkLaunch threads the host-service client through. Contributors (already landed earlier this session): - GitHub issue header: `# GitHub Issue #N — Title` - PR header: `# PR #N — Title` + "This PR is checked out in this workspace on branch `X`. Commits you make here will be added to this PR." - Task header: `# Task ID — Title` - Attachments block: framing header cueing the agent to read the files. 77 tests green. Typecheck clean. --- .../$pendingId/buildForkAgentLaunch.ts | 120 +++++++++++++++++- .../pending/$pendingId/dispatchForkLaunch.ts | 5 +- .../workspace-creation/workspace-creation.ts | 37 ++++++ 3 files changed, 160 insertions(+), 2 deletions(-) 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 index 9845232207a..201042300f7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -1,4 +1,5 @@ import { isTerminalAgentDefinition } from "@superset/shared/agent-catalog"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import type { PendingChatLaunch, PendingTerminalLaunch, @@ -35,6 +36,43 @@ export interface BuildForkAgentLaunchInputs { >; 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; + }>; + }; + }; + }; } /** @@ -98,7 +136,10 @@ export async function buildForkAgentLaunch( }, { contributors: defaultContributorRegistry, - resolveCtx: buildResolveCtxFromPending(inputs.pending), + resolveCtx: buildResolveCtxFromPending( + inputs.pending, + inputs.hostServiceClient, + ), }, ); const spec = buildLaunchSpec(ctx, agentConfig); @@ -341,12 +382,22 @@ function dataUrlAttachmentToBytes(loaded: LoadedAttachment): AttachmentFile { }; } +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, @@ -356,6 +407,30 @@ function buildResolveCtxFromPending( 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, @@ -364,12 +439,37 @@ function buildResolveCtxFromPending( 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, @@ -378,6 +478,7 @@ function buildResolveCtxFromPending( branch: "", }; }, + fetchInternalTask: async (id) => { const match = pending.linkedIssues.find( (i) => i.source === "internal" && i.taskId === id, @@ -387,6 +488,23 @@ function buildResolveCtxFromPending( status: 404, }); } + + // Fetch full task from Superset cloud API (same source as task view). + try { + const task = await apiTrpcClient.task.byId.query(id); + 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, 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 index 38533675bcf..1b4d18999bf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -52,12 +52,16 @@ export async function dispatchForkLaunch({ 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); @@ -107,7 +111,6 @@ export async function dispatchForkLaunch({ return; } - const hostUrl = resolveHostUrl(pending.hostTarget, activeHostUrl); if (!hostUrl) { console.warn("[v2-launch] host-service URL not resolved; skip launch"); toast.error("Couldn't reach host service", { 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 207b14b388b..5479bb36b4f 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 @@ -1301,4 +1301,41 @@ 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); + const octokit = await ctx.github(); + try { + 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, + isDraft: data.draft ?? false, + createdAt: data.created_at, + updatedAt: data.updated_at, + }; + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to fetch PR #${input.prNumber}: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), }); From 67d15e496216947d547c399014a26cffe9c94e6c Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 23:17:44 -0700 Subject: [PATCH 39/42] chore(desktop): fix biome warning + stale doc comment in fork launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internalTask.test.ts: replace `TASK.description!` non-null assertion with `if (TASK.description)` guard (biome lint/style/noNonNullAssertion). - buildForkAgentLaunch.ts: update stale docstring that claimed bodies aren't fetched yet — they are, via host-service and the cloud task API. 77 tests green, biome clean, typecheck clean. --- .../$pendingId/buildForkAgentLaunch.ts | 19 ++++++------------- .../context/contributors/internalTask.test.ts | 2 +- 2 files changed, 7 insertions(+), 14 deletions(-) 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 index 201042300f7..2abbf9ea988 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -44,10 +44,7 @@ export interface BuildForkAgentLaunchInputs { hostServiceClient?: { workspaceCreation: { getGitHubIssueContent: { - query: (input: { - projectId: string; - issueNumber: number; - }) => Promise<{ + query: (input: { projectId: string; issueNumber: number }) => Promise<{ number: number; title: string; body: string; @@ -57,10 +54,7 @@ export interface BuildForkAgentLaunchInputs { }>; }; getGitHubPullRequestContent: { - query: (input: { - projectId: string; - prNumber: number; - }) => Promise<{ + query: (input: { projectId: string; prNumber: number }) => Promise<{ number: number; title: string; body: string; @@ -105,11 +99,10 @@ export type PendingLaunchBuild = * to disk). Returns null for no-op launches (e.g. no sources, no agent * enabled). * - * Phase 1 note: issue / PR / task bodies are not fetched over HTTP yet - * (host-service lacks a body endpoint). The resolver returns empty - * bodies — the agent sees title/URL/task-slug metadata only. When - * host-service grows getIssueContent / getPullRequestContent / - * getInternalTaskContent, swap the resolver stubs here. + * 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, diff --git a/apps/desktop/src/shared/context/contributors/internalTask.test.ts b/apps/desktop/src/shared/context/contributors/internalTask.test.ts index c11bfa3e80e..0651d5449ec 100644 --- a/apps/desktop/src/shared/context/contributors/internalTask.test.ts +++ b/apps/desktop/src/shared/context/contributors/internalTask.test.ts @@ -40,7 +40,7 @@ describe("internalTaskContributor", () => { 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}`); - expect(text).toContain(TASK.description!); + if (TASK.description) expect(text).toContain(TASK.description); expect(section?.meta).toEqual({ taskSlug: TASK.slug }); }); From a8838ebc938b9ace47c1cdb9bd88cf330b912003 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Wed, 15 Apr 2026 23:40:30 -0700 Subject: [PATCH 40/42] fix(host-service): shell out to gh CLI for issue/PR content (V1 parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit host-service's octokit path needs a GitHub token from providers.credentials.getToken("github.com") — which most users don't have set up (requires GITHUB_TOKEN env or git credential helper config for github.com). Result: getGitHubIssueContent / getGitHubPullRequestContent silently 500'd, buildForkAgentLaunch fell back to title-only, and the agent received empty bodies for linked issues/PRs. V1's projects.getIssueContent shells out to `gh issue view` via the user's `gh auth login` — that works out of the box. Port the same approach: - New packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts — promisified execFile("gh", ...) with user shell env so PATH resolves on macOS GUI contexts. - getGitHubIssueContent now calls `gh issue view <n> --repo owner/name --json number,title,body,url,state,author,createdAt,updatedAt`. - getGitHubPullRequestContent calls `gh pr view <n> --repo owner/name --json number,title,body,url,state,author,headRefName,baseRefName,isDraft,...`. - Zod-validate the JSON output before returning. - Normalize state to lowercase (gh returns "OPEN"/"CLOSED" uppercase). Drops the Octokit dependency on these two procedures. Other host-service paths that still use ctx.github() unchanged. --- .../workspace-creation/utils/exec-gh.ts | 28 ++++++ .../workspace-creation/workspace-creation.ts | 89 +++++++++++++------ 2 files changed, 92 insertions(+), 25 deletions(-) create mode 100644 packages/host-service/src/trpc/router/workspace-creation/utils/exec-gh.ts 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 5479bb36b4f..812d8ace82d 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"; @@ -1268,6 +1269,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({ @@ -1277,22 +1283,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({ @@ -1311,25 +1321,29 @@ 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.pulls.get({ - owner: repo.owner, - repo: repo.name, - pull_number: input.prNumber, - }); + 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.html_url, - state: data.state, - branch: data.head.ref, - baseBranch: data.base.ref, - author: data.user?.login ?? null, - isDraft: data.draft ?? false, - createdAt: data.created_at, - updatedAt: data.updated_at, + 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({ @@ -1339,3 +1353,28 @@ export const workspaceCreationRouter = router({ } }), }); + +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(), +}); From 5d6b449088fe22632274e4595e4adb3f8e4dd1a3 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Thu, 16 Apr 2026 00:30:50 -0700 Subject: [PATCH 41/42] fix typecheck --- .../pending/$pendingId/buildForkAgentLaunch.ts | 14 ++++++++------ packages/shared/src/agent-prompt-template.ts | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) 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 index 2abbf9ea988..141b6d4f241 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -485,12 +485,14 @@ function buildResolveCtxFromPending( // Fetch full task from Superset cloud API (same source as task view). try { const task = await apiTrpcClient.task.byId.query(id); - return { - id: task.id, - slug: match.slug || slugifyTitle(task.title), - title: task.title, - description: task.description ?? null, - }; + 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`, diff --git a/packages/shared/src/agent-prompt-template.ts b/packages/shared/src/agent-prompt-template.ts index 5234166110c..807d487b14b 100644 --- a/packages/shared/src/agent-prompt-template.ts +++ b/packages/shared/src/agent-prompt-template.ts @@ -36,7 +36,8 @@ function substituteOwnProperties( /\{\{\s*([^}]+?)\s*\}\}/g, (match, rawKey: string) => { const key = rawKey.trim(); - return Object.hasOwn(variables, key) ? variables[key] : match; + if (!Object.hasOwn(variables, key)) return match; + return variables[key] ?? match; }, ); } From 5d88b1ea5e687da393d0106658dc4184b5e36259 Mon Sep 17 00:00:00 2001 From: Kiet Ho <hoakiet98@gmail.com> Date: Thu, 16 Apr 2026 00:46:09 -0700 Subject: [PATCH 42/42] clean up --- apps/desktop/src/shared/context/composer.ts | 2 +- plans/{ => done}/v2-workspace-context-composition.md | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename plans/{ => done}/v2-workspace-context-composition.md (100%) diff --git a/apps/desktop/src/shared/context/composer.ts b/apps/desktop/src/shared/context/composer.ts index aa4388320f0..17de2ec10a0 100644 --- a/apps/desktop/src/shared/context/composer.ts +++ b/apps/desktop/src/shared/context/composer.ts @@ -57,7 +57,7 @@ export async function buildLaunchContext( return { projectId: inputs.projectId, - sources: inputs.sources, + sources: deduped, sections, failures, taskSlug: deriveTaskSlug(sections), diff --git a/plans/v2-workspace-context-composition.md b/plans/done/v2-workspace-context-composition.md similarity index 100% rename from plans/v2-workspace-context-composition.md rename to plans/done/v2-workspace-context-composition.md