Skip to content

feat(threads): add agent-requested handoffs - #7487

Closed
mrmg wants to merge 1 commit into
pingdotgg:mainfrom
mrmg:t3code/agent-requested-thread-handoff
Closed

mrmg wants to merge 1 commit into
pingdotgg:mainfrom
mrmg:t3code/agent-requested-thread-handoff

Conversation

@mrmg

@mrmg mrmg commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a durable, agent-requested thread handoff lifecycle.

  • Exposes an in-thread MCP handoff request and settles it only after the requesting turn succeeds.
  • Adds user-confirmed Open/Dismiss handoff cards on web/desktop and mobile.
  • Creates linked, unstarted target drafts with inherited settings and an editable seeded composer prompt.
  • Migrates Plan → new-thread to the same handoff path.

Why

Agents can now prepare a new-thread continuation without creating or starting it themselves; users retain the final decision to open, edit, and send the target draft.

Validation

  • Scoped contracts, client-runtime, server, web, and mobile typechecks
  • 8 focused suites / 147 tests
  • git diff --check

Built with gpt-5.6-terra via Codex.

Note

Add agent-requested thread handoffs across server, client, and mobile

  • Introduces a full thread handoff lifecycle (request → available → accept/dismiss/cancel) via new orchestration commands, server-side decider logic, and contract schemas in packages/contracts/src/orchestration.ts.
  • Adds a request_thread_handoff MCP tool so AI providers can programmatically hand off to a new thread with a title, prompt, and artifact references.
  • On turn completion or abortion, ProviderRuntimeIngestion detects pending handoffs and dispatches thread.handoff.turn-settle to resolve them.
  • Web ChatView and mobile ThreadRouteScreen surface available handoffs as cards with Open/Dismiss actions and auto-seed the composer prompt when a handoff is accepted.
  • Composer drafts on both web and mobile now persist seededHandoffIds so a cleared handoff prompt is not reinserted on reload.
📊 Macroscope summarized d8fac27. 19 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@github-actions github-actions Bot added the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Aug 19, 2026
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 88d6270c-7c6d-449c-9fd7-cedc140b0160

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the size:XXL 1,000+ changed lines (additions + deletions). label Aug 19, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One consistency finding in the new web handoff card: the action controls re-create the shared Button primitive as raw <button> elements and lose its interaction/accessibility behavior. Details inline.

Posted via Macroscope — UI Consistency

Comment on lines +25 to +40
<button
type="button"
disabled={busy}
onClick={onOpen}
className="rounded-md bg-primary px-2.5 py-1.5 font-medium text-primary-foreground disabled:opacity-50"
>
Open {handoff.title} thread
</button>
<button
type="button"
disabled={busy}
onClick={onDismiss}
className="rounded-md px-2.5 py-1.5 text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Dismiss
</button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two raw <button> elements reconstruct the shared Button primitive (~/components/ui/button) and drop behavior it owns: cursor-pointer, the focus-visible:ring-2 ring-ring ring-offset-* ring, disabled:pointer-events-none (currently only opacity changes, so a busy button still receives hover/click), the rounded-[var(--control-radius)] token instead of a hard-coded rounded-md, the primary variant's border/inset-shadow and pressed states, and the pointer-coarse 44px minimum hit target. The sibling banner in the same stack (ThreadErrorBanner) composes Alert + Button for exactly this reason.

Suggest using the primitive with its sm size and ghost variant, and adding import { Button } from "../ui/button"; at the top of the file (Button already defaults type="button"):

Suggested change
<button
type="button"
disabled={busy}
onClick={onOpen}
className="rounded-md bg-primary px-2.5 py-1.5 font-medium text-primary-foreground disabled:opacity-50"
>
Open {handoff.title} thread
</button>
<button
type="button"
disabled={busy}
onClick={onDismiss}
className="rounded-md px-2.5 py-1.5 text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Dismiss
</button>
<Button size="sm" disabled={busy} onClick={onOpen}>
Open {handoff.title} thread
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={onDismiss}>
Dismiss
</Button>

Posted via Macroscope — UI Consistency

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the new Effect-facing code (MCP handoff toolkit, orchestration decider/handoff helpers, client-runtime commands) against the service conventions. Service acquisition, layer wiring, and effect/* subpath namespace imports look correct; the findings below are about how the new MCP tool failure is modeled.

Posted via Macroscope — Effect Service Conventions

Comment on lines +18 to +20
export const ThreadHandoffToolFailure = Schema.Struct({
message: Schema.String,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool failure is an unstructured { message: string } struct, which stores the message as the only data. Consider a Schema.TaggedErrorClass with stable structural attributes plus a cause, deriving message from those attributes (the sibling preview toolkit already uses tagged PreviewAutomationError classes for failure).

-export const ThreadHandoffToolFailure = Schema.Struct({
-  message: Schema.String,
-});
+export class ThreadHandoffToolError extends Schema.TaggedErrorClass<ThreadHandoffToolError>()(
+  "ThreadHandoffToolError",
+  {
+    threadId: ThreadId,
+    handoffId: ThreadHandoffId,
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Unable to request a thread handoff for thread ${this.threadId}.`;
+  }
+}

This also needs ThreadId added to the @t3tools/contracts import and failure: ThreadHandoffToolError on the tool.

Posted via Macroscope — Effect Service Conventions

Comment on lines +7 to +8
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this service boundary the local service module is imported by named tag instead of as a namespace (same in handlers.ts and tools.test.ts). Suggest matching the adjacent McpInvocationContext/PreviewAutomationBroker style so the module shape stays visible:

Suggested change
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts";

References then become OrchestrationEngine.OrchestrationEngineService.

Posted via Macroscope — Effect Service Conventions

Comment on lines +35 to +38
requestThreadHandoff(input).pipe(
Effect.mapError((error) => ({
message: error instanceof Error ? error.message : "Unable to request a thread handoff.",
})),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapError erases the structured OrchestrationDispatchError into a string and derives the wrapper message from cause.message, dropping the cause chain. Consider constructing a tagged error at the failure boundary (the dispatch call) so threadId/handoffId and the original error survive:

-    yield* orchestrationEngine.dispatch({
+    yield* orchestrationEngine.dispatch({
       type: "thread.handoff.request",
       ...
-    });
+    }).pipe(
+      Effect.mapError(
+        (cause) =>
+          new ThreadHandoffToolError({
+            threadId: invocation.threadId,
+            handoffId: ThreadHandoffId.make(handoffId),
+            cause,
+          }),
+      ),
+    );

The toolkit entry can then be request_thread_handoff: (input) => requestThreadHandoff(input) with no message stringification.

Posted via Macroscope — Effect Service Conventions

if (failure === null) {
const startedResult = await settlePromise(() =>
waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)),
seedHandoffPrompt(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/ChatView.tsx:5772

The Plan → new-thread flow leaves the target draft in plan mode, so sending the seeded PLEASE IMPLEMENT THIS PLAN prompt starts another planning turn instead of implementing the plan. acceptThreadHandoff inherits the source thread's mode, and this path only seeds the prompt; set the target draft's interaction mode to default before navigating.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5772:

The Plan → new-thread flow leaves the target draft in `plan` mode, so sending the seeded `PLEASE IMPLEMENT THIS PLAN` prompt starts another planning turn instead of implementing the plan. `acceptThreadHandoff` inherits the source thread's mode, and this path only seeds the prompt; set the target draft's interaction mode to `default` before navigating.


if (failure === null) {
const startResult = await startThreadTurn({
const acceptResult = await acceptThreadHandoff({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/ChatView.tsx:5760

onImplementPlanInNewThread creates the target through acceptThreadHandoff, so the new thread inherits the source thread’s persisted runtimeMode and ignores the composer’s current mode. A user who switches supervision or access mode before opening the implementation thread therefore gets the wrong permission mode; carry runtimeMode through the handoff or apply it to the target before navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5760:

`onImplementPlanInNewThread` creates the target through `acceptThreadHandoff`, so the new thread inherits the source thread’s persisted `runtimeMode` and ignores the composer’s current mode. A user who switches supervision or access mode before opening the implementation thread therefore gets the wrong permission mode; carry `runtimeMode` through the handoff or apply it to the target before navigation.


if (failure === null) {
const startResult = await startThreadTurn({
const acceptResult = await acceptThreadHandoff({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/ChatView.tsx:5760

Choosing “implement in new thread” drops the composer’s current sendCtx.selectedModelSelection, so the accepted target is created with the source thread’s persisted model/provider instead of the user’s latest selection. This also means outgoingImplementationPrompt may be formatted for a different provider than the target uses; pass the current model selection through the handoff creation/accept path.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5760:

Choosing “implement in new thread” drops the composer’s current `sendCtx.selectedModelSelection`, so the accepted target is created with the source thread’s persisted model/provider instead of the user’s latest selection. This also means `outgoingImplementationPrompt` may be formatted for a different provider than the target uses; pass the current model selection through the handoff creation/accept path.

return (
<View
accessibilityLabel={presentation.accessibilityLabel}
className="absolute left-3 right-3 top-2 z-20 rounded-2xl border border-border bg-surface px-3 py-2 shadow-sm"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadHandoffCard.tsx:18

When availableHandoffs contains multiple items, only the topmost ThreadHandoffCard is usable; every card renders at the same absolute left-3 right-3 top-2 position, so earlier handoffs and their Open/Dismiss controls are covered. Render a single handoff or give the mapped cards a non-overlapping layout.

Also found in 1 other location(s)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx:590

Every ThreadHandoffCard rendered by this map uses the same absolute left-3 right-3 top-2 position. When availableHandoffs contains more than one item, the cards stack directly on top of each other, hiding the earlier handoffs and making their Open/Dismiss controls inaccessible. Render only one card or give the collection a non-overlapping layout.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadHandoffCard.tsx around line 18:

When `availableHandoffs` contains multiple items, only the topmost `ThreadHandoffCard` is usable; every card renders at the same absolute `left-3 right-3 top-2` position, so earlier handoffs and their Open/Dismiss controls are covered. Render a single handoff or give the mapped cards a non-overlapping layout.

Also found in 1 other location(s):
- apps/mobile/src/features/threads/ThreadDetailScreen.tsx:590 -- Every `ThreadHandoffCard` rendered by this map uses the same absolute `left-3 right-3 top-2` position. When `availableHandoffs` contains more than one item, the cards stack directly on top of each other, hiding the earlier handoffs and making their Open/Dismiss controls inaccessible. Render only one card or give the collection a non-overlapping layout.

command,
threadId: command.threadId,
});
const requestingTurnId = command.requestingTurnId ?? thread.session?.activeTurnId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration/decider.ts:1240

A client can bind thread.handoff.request to an old or arbitrary requestingTurnId, leaving the durable handoff permanently pending when that turn's completion has already been ingested and no future thread.handoff.turn-settle can resolve it. Reject an explicitly supplied ID unless it matches the current active turn, while retaining the completed-turn exception for availableImmediately requests.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 1240:

A client can bind `thread.handoff.request` to an old or arbitrary `requestingTurnId`, leaving the durable handoff permanently `pending` when that turn's completion has already been ingested and no future `thread.handoff.turn-settle` can resolve it. Reject an explicitly supplied ID unless it matches the current active turn, while retaining the completed-turn exception for `availableImmediately` requests.

}, [interruptThreadTurn, selectedThread]);
const handleOpenHandoff = useCallback(
async (handoff: ReturnType<typeof findThreadHandoffs>[number]) => {
if (!selectedThread || handoffActionId !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadRouteScreen.tsx:524

Two rapid Open/Dismiss presses both observe handoffActionId === null before React commits the state update, so they dispatch concurrent handoff commands; duplicate Open presses can create different target threads, while Open/Dismiss can race and produce a spurious failure or navigation. Use a synchronously updated ref/lock before awaiting either command.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 524:

Two rapid Open/Dismiss presses both observe `handoffActionId === null` before React commits the state update, so they dispatch concurrent handoff commands; duplicate Open presses can create different target threads, while Open/Dismiss can race and produce a spurious failure or navigation. Use a synchronously updated ref/lock before awaiting either command.

@t3dotgg

t3dotgg commented Aug 23, 2026

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. Adds an unsolicited product feature or cross-system architecture change that exceeds the contribution policy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants