-
Notifications
You must be signed in to change notification settings - Fork 6.5k
fix(clients): native subagent threads show status instead of a composer #13624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f7dbed9
6a93fae
280bd06
b6d6fa1
d4585a1
a9d1ac3
135d0bf
44f8e2f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { | ||
| formatProviderSubagentStatus, | ||
| type ProviderSubagentStatus, | ||
| } from "@t3tools/client-runtime/state/thread-execution"; | ||
| import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; | ||
| import { useEffect, useState } from "react"; | ||
| import { View } from "react-native"; | ||
|
|
||
| import { AppText as Text } from "../../components/AppText"; | ||
| import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| import { RequestActionButton } from "./RequestActionButton"; | ||
|
|
||
| /** | ||
| * Replaces the composer on a provider-native subagent thread. The provider | ||
| * runs that conversation, so there is nothing to send; the bar says which | ||
| * model is working, for how long, and leads back to the parent. | ||
| */ | ||
| export function ProviderSubagentBar(props: { | ||
| /** Driver and catalog icon of the provider running the subagent. */ | ||
| readonly provider: { readonly driver: string; readonly iconUrl?: string | undefined } | null; | ||
| readonly modelLabel: string; | ||
| /** Reasoning effort as the composer names it, when the subagent has one. */ | ||
| readonly effortLabel: string | null; | ||
| /** Null until the subagent's root turn arrives. */ | ||
| readonly status: ProviderSubagentStatus | null; | ||
| readonly onOpenParent: (() => void) | null; | ||
| }) { | ||
| const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); | ||
| const [nowMs, setNowMs] = useState(() => Date.now()); | ||
| useEffect(() => { | ||
| if (!live) return; | ||
| const id = setInterval(() => setNowMs(Date.now()), 1_000); | ||
| return () => clearInterval(id); | ||
| }, [live]); | ||
| const statusLabel = formatProviderSubagentStatus(props.status, nowMs); | ||
| const modelDescription = | ||
| props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; | ||
|
|
||
| return ( | ||
| <View className="flex-row items-center gap-3 rounded-[20px] border border-border-subtle bg-card-alt py-2 pe-2 ps-4"> | ||
| {/* Only the text is one element, so "Open parent" stays reachable. */} | ||
| <View | ||
| accessible | ||
| accessibilityLabel={`${modelDescription} subagent, ${statusLabel}. It runs on its own and cannot take messages.`} | ||
| className="min-w-0 flex-1 gap-0.5" | ||
| > | ||
| <View className="min-w-0 flex-row items-center gap-1.5"> | ||
| {props.provider ? ( | ||
| <ProviderIcon | ||
| iconUrl={props.provider.iconUrl} | ||
| provider={props.provider.driver} | ||
| size={16} | ||
| /> | ||
| ) : null} | ||
| <Text numberOfLines={1} className="min-w-0 shrink font-t3-bold text-sm text-foreground"> | ||
| {props.modelLabel} | ||
| </Text> | ||
| {props.effortLabel === null ? null : ( | ||
| <Text | ||
| numberOfLines={1} | ||
| className="shrink-0 font-sans text-sm text-foreground-secondary" | ||
| > | ||
| {props.effortLabel} | ||
| </Text> | ||
| )} | ||
| </View> | ||
| <Text | ||
| numberOfLines={1} | ||
| className="font-sans text-xs text-foreground-secondary" | ||
| style={{ fontVariant: ["tabular-nums"] }} | ||
| > | ||
| {statusLabel} · Runs on its own | ||
| </Text> | ||
| </View> | ||
| {props.onOpenParent ? ( | ||
| <RequestActionButton label="Open parent" tone="secondary" onPress={props.onOpenParent} /> | ||
| ) : null} | ||
| </View> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import { | |
| import { | ||
| type ChatAttachment, | ||
| CommandId, | ||
| isProviderNativeSubagentThread, | ||
| MessageId, | ||
| type ModelSelection, | ||
| OrchestrationV2Command, | ||
|
|
@@ -155,6 +156,15 @@ export class OrchestratorProviderAdapterError extends Schema.TaggedError<Orchest | |
| } | ||
| } | ||
|
|
||
| export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError<OrchestratorSubagentThreadReadOnlyError>()( | ||
| "OrchestratorSubagentThreadReadOnlyError", | ||
| { commandId: CommandId, threadId: ThreadId }, | ||
| ) { | ||
| override get message(): string { | ||
| return "This subagent is run by its provider and cannot take messages. Message the parent thread instead."; | ||
| } | ||
| } | ||
|
|
||
| export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError<OrchestratorCommandPreviouslyRejectedError>()( | ||
| "OrchestratorCommandPreviouslyRejectedError", | ||
| { | ||
|
|
@@ -203,6 +213,7 @@ export const OrchestratorV2Error = Schema.Union([ | |
| OrchestratorProviderAdapterError, | ||
| OrchestratorCommandPreviouslyRejectedError, | ||
| OrchestratorCommandIdConflictError, | ||
| OrchestratorSubagentThreadReadOnlyError, | ||
| ]); | ||
| export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; | ||
|
|
||
|
|
@@ -8767,9 +8778,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| case "provider-session.detach": | ||
| yield* dispatchProviderSessionDetach(command, events, effects); | ||
| break; | ||
| case "message.dispatch": | ||
| case "message.dispatch": { | ||
| // The provider owns a native subagent's conversation, so a sent | ||
| // message has nowhere to go. Only sends are refused: answers to the | ||
| // subagent's own questions reuse dispatchMessage and must still land. | ||
| const thread = yield* projectionStore | ||
| .getThread(command.threadId) | ||
| .pipe( | ||
| Effect.mapError( | ||
| (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), | ||
| ), | ||
| ); | ||
| if (isProviderNativeSubagentThread(thread)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High Pre-existing queued sends still execute on provider-native subagent threads, bypassing the new read-only protection. 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| return yield* new OrchestratorSubagentThreadReadOnlyError({ | ||
| commandId: command.commandId, | ||
| threadId: command.threadId, | ||
| }); | ||
| } | ||
| yield* dispatchMessage(command, events, effects); | ||
| break; | ||
| } | ||
| case "notification.delivery.accept": | ||
| yield* dispatchNotificationAccepted(command, events); | ||
| break; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
threads/ThreadDetailScreen.tsx:1233Provider-native threads still expose
ThreadFeed's pending-message edit action, but this branch renders onlyProviderSubagentBar, so an edited outbox message has no composer in which to modify or send it. Pressing the visibleComposerQueuedEditBannercancel action then clears the queued-edit draft and permanently discards the message. Disable pending-message editing and its queued-edit banner for provider-native threads, or render a composer that can complete the edit.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: