@@ -5,6 +5,38 @@ import type {
55 LanguageModelV3Usage ,
66} from "@ai-sdk/provider" ;
77import type { CursorEvent , CursorUsage } from "./agent-events.js" ;
8+ import { linkSubagentSession } from "./subagent-bridge.js" ;
9+
10+ /** Per-turn context threaded from the language model into the stream mapper. */
11+ export interface StreamContext {
12+ /** Parent opencode session id, used to link Cursor subagents as children. */
13+ sessionID ?: string ;
14+ }
15+
16+ /** Cursor's subagent tool name (the `task` tool-call/tool-result event name). */
17+ const TASK_TOOL_NAME = "task" ;
18+
19+ /**
20+ * Inject a linked child-session id into an already-folded `task` tool-result
21+ * part so the TUI renders the card as clickable / `ctrl+x`-navigable. The
22+ * folded result object lives on `part.result` ({@link nativeToolResult}); its
23+ * `metadata` is a fresh object per call, so mutation here is safe.
24+ */
25+ function injectSubagentSessionId (
26+ parts : BlockToolPart [ ] ,
27+ sessionId : string ,
28+ ) : void {
29+ for ( const part of parts ) {
30+ if ( part . type !== "tool-result" || part . toolName !== "task" ) continue ;
31+ const folded = ( part as { result ?: unknown } ) . result ;
32+ if ( ! isRecord ( folded ) ) continue ;
33+ const metadata = isRecord ( folded [ "metadata" ] )
34+ ? ( folded [ "metadata" ] as Record < string , unknown > )
35+ : { } ;
36+ metadata [ "sessionId" ] = sessionId ;
37+ ( folded as Record < string , unknown > ) [ "metadata" ] = metadata ;
38+ }
39+ }
840
941/**
1042 * How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced to
@@ -158,6 +190,20 @@ function successValue(result: unknown): unknown {
158190 : undefined ;
159191}
160192
193+ /**
194+ * The opencode `task` input's `subagent_type` field, or `{}` to omit it. Prefers
195+ * Cursor's real subagent `name`; accepts a `kind` only when it isn't the proto
196+ * zero-value `"unspecified"` (which the TUI would titlecase to "Unspecified
197+ * Task"). Omitting the field makes the TUI fall back to "General Task".
198+ */
199+ function subagentTypeField ( args : unknown ) : { subagent_type ?: string } {
200+ const sub = isRecord ( args ) ? args [ "subagentType" ] : undefined ;
201+ const name = strField ( sub , "name" ) ;
202+ const kind = strField ( sub , "kind" ) ;
203+ const subagent = name ?? ( kind && kind !== "unspecified" ? kind : undefined ) ;
204+ return subagent ? { subagent_type : subagent } : { } ;
205+ }
206+
161207/** The `{title, metadata, output}` shape opencode folds into a tool part's state. */
162208interface FoldedResult {
163209 title : string ;
@@ -556,19 +602,16 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = {
556602 } ,
557603 } ,
558604 // Cursor `task` (subagent) → opencode `task` (agent card: name + description).
559- // Non-clickable here — the subagent ran inside Cursor, not as an opencode
560- // child session — but the native card reads far better than raw JSON.
605+ // Made navigable in the stream layer: when a parent session + the plugin
606+ // bridge are present, a real opencode child session is created and its id is
607+ // stamped onto this result's `metadata.sessionId` (see cursorEventsToStream's
608+ // tool-result path). Without that link it degrades to a non-navigable card.
561609 task : {
562610 tool : "task" ,
563- input : ( args ) => {
564- const description = strField ( args , "description" ) ?? "" ;
565- const sub = isRecord ( args ) ? args [ "subagentType" ] : undefined ;
566- const subagent =
567- strField ( sub , "name" ) ?? strField ( sub , "kind" ) ?? undefined ;
568- return subagent
569- ? { description, subagent_type : subagent }
570- : { description } ;
571- } ,
611+ input : ( args ) => ( {
612+ description : strField ( args , "description" ) ?? "" ,
613+ ...subagentTypeField ( args ) ,
614+ } ) ,
572615 result : ( value , args ) => {
573616 const description = strField ( args , "description" ) ?? "" ;
574617 const suffix = strField ( value , "resultSuffix" ) ;
@@ -1029,6 +1072,7 @@ const DANGLING_TOOL_RESULT = {
10291072export function cursorEventsToStream (
10301073 events : AsyncIterable < CursorEvent > ,
10311074 toolDisplay : ToolDisplay = "blocks" ,
1075+ ctx : StreamContext = { } ,
10321076) : ReadableStream < LanguageModelV3StreamPart > {
10331077 return new ReadableStream < LanguageModelV3StreamPart > ( {
10341078 async start ( controller ) {
@@ -1183,6 +1227,13 @@ export function cursorEventsToStream(
11831227 case "tool-result" :
11841228 if ( toolState . dropped . delete ( event . id ) ) break ;
11851229 if ( toolDisplay === "blocks" ) {
1230+ // Capture the original call args before blockToolResultParts
1231+ // clears the open-tool entry — the subagent link needs the
1232+ // task description/prompt, which live on the call, not result.
1233+ const taskArgs =
1234+ event . name === TASK_TOOL_NAME
1235+ ? toolState . open . get ( event . id ) ?. args
1236+ : undefined ;
11861237 const parts = blockToolResultParts (
11871238 event . id ,
11881239 event . name ,
@@ -1194,6 +1245,23 @@ export function cursorEventsToStream(
11941245 closeText ( ) ;
11951246 closeReasoning ( ) ;
11961247 }
1248+ // A Cursor subagent ran inside Cursor (no opencode child
1249+ // session). Create a real child session now and point the task
1250+ // card at it so it's clickable / ctrl+x-navigable. Best-effort:
1251+ // linkSubagentSession swallows all failures and returns
1252+ // undefined, leaving the card exactly as before.
1253+ if (
1254+ event . name === TASK_TOOL_NAME &&
1255+ ! event . isError &&
1256+ ctx . sessionID
1257+ ) {
1258+ const childId = await linkSubagentSession ( {
1259+ parentSessionID : ctx . sessionID ,
1260+ args : taskArgs ,
1261+ result : event . result ,
1262+ } ) ;
1263+ if ( childId ) injectSubagentSessionId ( parts , childId ) ;
1264+ }
11971265 for ( const part of parts ) {
11981266 controller . enqueue ( part ) ;
11991267 }
0 commit comments