diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6169700132233..f67a39904a06e 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -61,6 +61,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion]: AuthOrchestrationReadScope, [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 3a1e732121f1d..dca9d79415263 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -33,6 +33,7 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: "orchestrationV2", + [ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion]: "orchestrationV2", [WS_METHODS.projectsMutate]: "orchestration", [WS_METHODS.serverProbe]: "server", [WS_METHODS.serverGetConfig]: "server", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d03c779b4bb..4877eed64f146 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3159,6 +3159,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly freshQueueOnReopen?: boolean; readonly forkSession?: ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerShape["forkSession"]; readonly allocateSessionId?: Effect.Effect; + readonly promptSuggestions?: boolean; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -3329,6 +3330,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerSessionId: ProviderSessionId.make("provider-session-claude-wake"), modelSelection: CLAUDE_TEST_MODEL_SELECTION, runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + promptSuggestions: Effect.succeed(options?.promptSuggestions === true), }); const providerThread = yield* runtime.ensureThread({ threadId, @@ -7864,6 +7866,101 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each(["root", "mcp", "subagent", "remote"] as const)( + "asks Claude for suggestions only on threads a person drives (%s)", + (lineage) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ promptSuggestions: true }); + const base = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-prompt-suggestion"), + text: "Add a subtract function.", + attachments: [], + }); + const parentThreadId = ThreadId.make("thread-claude-suggestion-parent"); + // MCP-launched root threads carry `delegatedFrom: null` at runtime. + const offered = lineage === "root" || lineage === "mcp"; + const input = + lineage === "root" + ? base + : lineage === "mcp" + ? { + ...base, + appThread: { + ...base.appThread, + delegatedFrom: null as unknown as undefined, + }, + } + : lineage === "subagent" + ? { + ...base, + appThread: { + ...base.appThread, + lineage: { + parentThreadId, + relationshipToParent: "subagent" as const, + rootThreadId: parentThreadId, + }, + }, + } + : { + ...base, + appThread: { + ...base.appThread, + delegatedFrom: { + environmentId: EnvironmentId.make("environment-claude-suggestion-parent"), + threadId: parentThreadId, + title: "Parent", + }, + }, + }; + yield* harness.runtime.startTurn(input); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000690", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + // Delegated threads have no one typing, so the SDK is never asked for suggestions. + assert.equal(harness.getOpenedOptions()?.promptSuggestions, offered); + if (!offered) return; + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "prompt_suggestion", + suggestion: " add tests for subtract\n", + uuid: "00000000-0000-4000-8000-000000000691", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => + harness.events.some((event) => event.type === "prompt_suggestion.updated"), + ); + assert.deepEqual( + harness.events.filter((event) => event.type === "prompt_suggestion.updated"), + [ + { + type: "prompt_suggestion.updated", + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + threadId: harness.threadId, + runId: input.runId, + text: "add tests for subtract", + }, + ], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("merges cumulative stream usage into the message_start counts", () => Effect.gen(function* () { const harness = yield* makeWakeHarnessWithOptions({ @@ -12452,6 +12549,49 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); assert.isFalse(yield* harness.hasPendingBackgroundWork); + + // The continuation settled with no result of its own, so a late suggestion belongs to B. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "prompt_suggestion", + suggestion: "Check the build logs", + uuid: "00000000-0000-4000-8000-000000000692", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const userTurn = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-4d"), + text: "Ship it.", + attachments: [], + providerTurnOrdinal: 4, + }); + yield* harness.runtime.startTurn(userTurn); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000693", result: "Shipped." }), + ); + yield* harness.awaitTerminal(4); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "prompt_suggestion", + suggestion: "Open the release notes", + uuid: "00000000-0000-4000-8000-000000000694", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => + harness.events.some((event) => event.type === "prompt_suggestion.updated"), + ); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "prompt_suggestion.updated" ? [[event.runId, event.text]] : [], + ), + [[userTurn.runId, "Open the release notes"]], + ); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index df80f1316fb77..2824552b32ea7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -128,6 +128,7 @@ import { type RunId, type ThreadId, resolveIdleCompaction, + isDelegatedAppThread, } from "@t3tools/contracts"; import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; @@ -1758,6 +1759,7 @@ export function makeClaudeQueryOptions(input: { readonly cwd: string | null; readonly attachmentsDir?: string; readonly settings?: ClaudeSettings; + readonly promptSuggestions?: boolean; readonly sdkSettings?: string | ClaudeSdkSettings; readonly environment?: NodeJS.ProcessEnv; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; @@ -1842,6 +1844,7 @@ export function makeClaudeQueryOptions(input: { ? "bypassPermissions" : (input.permissionMode ?? "default")), includePartialMessages: true, + promptSuggestions: input.promptSuggestions === true, ...(compiledSelection.effort === undefined ? {} : { @@ -5102,6 +5105,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), openSession: Effect.fn("ClaudeAdapterV2.openSession")( function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { + const promptSuggestionsEnabled = input.promptSuggestions ?? Effect.succeed(false); const sessionScope = yield* Effect.scope; const now = yield* DateTime.now; const session = providerSession({ @@ -8821,6 +8825,27 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const message = input.message; + if (message.type === "prompt_suggestion") { + // Arrives after `result`; offer it only while the completed turn is still the latest. + // A continuation can settle from buffered frames first, so its suggestion may be a user turn's. + const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId); + const text = message.suggestion.trim(); + if ( + settled !== undefined && + !isClaudeProviderContinuationTurn(settled.input) && + text.length > 0 && + (yield* Ref.get(activeTurn)) === null + ) { + yield* emitProviderEvent({ + type: "prompt_suggestion.updated", + driver: CLAUDE_PROVIDER, + threadId: settled.input.threadId, + runId: settled.input.runId, + text, + }); + } + return; + } // Track before routing: idle-wake Monitors report tasks before replaying their tool calls. yield* trackClaudeMonitorCalls(message); // Update before routing because the idle wake gate reads this. @@ -10924,6 +10949,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, + // Delegated threads have no one typing, so they never ask for suggestions. + promptSuggestions: + !isDelegatedAppThread(turnInput.appThread) && (yield* promptSuggestionsEnabled), ...(launchArgSettings === undefined ? {} : { sdkSettings: claudeSdkLaunchArgSettings(launchArgSettings) }), diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index a734e9b797577..c9b9218ca1f27 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1238,6 +1238,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { readChildMetadata?: Parameters[2], beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], + promptSuggestions: Effect.Effect = Effect.succeed(false), ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1313,6 +1314,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), modelSelection: CODEX_TEST_MODEL_SELECTION, runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + promptSuggestions, }); const providerThread = yield* runtime.ensureThread({ threadId, @@ -1829,6 +1831,211 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each(["subagent", "remote", "toggled-off"] as const)( + "never samples a suggestion for a %s thread", + (delegation) => + Effect.gen(function* () { + const nativeThreadId = `suggest-${delegation}-thread`; + const prompt = "Continue the task."; + const first = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: `${delegation}-turn-1`, + prompt, + }); + const second = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: `${delegation}-turn-2`, + prompt, + }); + const completed = (nativeTurnId: string): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `${nativeTurnId} completed`, + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }); + // The second turn is a barrier: a stray sample from the first turn is sent before it ends. + const requests: Array = []; + const enabled = yield* Ref.make(true); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: `prompt-suggestion-${delegation}`, + entries: [ + ...first, + completed(`${delegation}-turn-1`), + ...second.slice(5, 7).map((entry) => withReplayRequestId(entry, 4)), + completed(`${delegation}-turn-2`), + ], + }), + undefined, + (method) => Effect.sync(() => requests.push(method)), + undefined, + undefined, + undefined, + Ref.get(enabled), + ); + // The flag is read per turn: turning it off after the session opened stops sampling. + if (delegation === "toggled-off") yield* Ref.set(enabled, false); + const base = makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`${delegation}-attempt-1`), + text: prompt, + }); + const parentThreadId = ThreadId.make("thread-codex-suggestion-parent"); + const turn = { + ...base, + appThread: + delegation === "toggled-off" + ? base.appThread + : delegation === "subagent" + ? { + ...base.appThread, + lineage: { + parentThreadId, + relationshipToParent: "subagent" as const, + rootThreadId: parentThreadId, + }, + } + : { + ...base.appThread, + delegatedFrom: { + environmentId: EnvironmentId.make("environment-codex-suggestion-parent"), + threadId: parentThreadId, + title: "Parent", + }, + }, + }; + yield* harness.runtime.startTurn(turn); + yield* harness.awaitTerminal(1); + yield* harness.runtime.startTurn({ + ...turn, + runId: RunId.make(`${delegation}-run-2`), + attemptId: RunAttemptId.make(`${delegation}-attempt-2`), + rootNodeId: NodeId.make(`${delegation}-root-2`), + runOrdinal: 2, + providerTurnOrdinal: 2, + }); + yield* harness.awaitTerminal(2); + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed", "completed"], + ); + assert.notInclude(requests, "thread/sample"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("samples a next-prompt suggestion after a completed root turn", () => + Effect.gen(function* () { + const nativeThreadId = "suggest-thread"; + const nativeTurnId = "suggest-turn"; + const prompt = "Add a subtract function."; + const transcript = makeCodexReplayTranscript({ + scenario: "prompt-suggestion", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + { + type: "expect_outbound", + label: "thread/sample", + frame: { + id: 4, + method: "thread/sample", + params: { + threadId: nativeThreadId, + expectedTurnId: nativeTurnId, + prompt: CodexAdapterV2.CODEX_PROMPT_SUGGESTION_PROMPT, + effort: "low", + }, + }, + }, + { + type: "emit_inbound", + label: "thread/sample", + frame: { + id: 4, + result: { + sourceTurnId: nativeTurnId, + responseId: "resp-suggest", + text: " add tests for subtract\n", + inputTokens: 11448, + cachedInputTokens: 11264, + outputTokens: 16, + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness( + transcript, + undefined, + undefined, + undefined, + undefined, + undefined, + Effect.succeed(true), + ); + const turnInput = makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("suggest-attempt"), + text: prompt, + }); + yield* harness.runtime.startTurn(turnInput); + yield* harness.until(() => + harness.events.some((event) => event.type === "prompt_suggestion.updated"), + ); + assert.deepEqual( + harness.events.filter((event) => event.type === "prompt_suggestion.updated"), + [ + { + type: "prompt_suggestion.updated", + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + threadId: harness.threadId, + runId: turnInput.runId, + text: "add tests for subtract", + }, + ], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + const assistantMessages = (events: ReadonlyArray) => events.filter( ( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 9eb80cf7a2143..831b240d8bba2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -38,6 +38,7 @@ import { ProviderDriverKind, ProviderSetupError, type ServerProviderCompatibilityAdvisory, + isDelegatedAppThread, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -231,6 +232,10 @@ const decodeCodexBackgroundTerminalTerminateResponse = Schema.decodeUnknownEffec const decodeCodexBackgroundTerminalsListResponse = Schema.decodeUnknownEffect( CodexBackgroundTerminalsListResponse, ); +const CodexThreadSampleResponse = Schema.Struct({ text: Schema.NullOr(Schema.String) }); +const decodeCodexThreadSampleResponse = Schema.decodeUnknownEffect(CodexThreadSampleResponse); +export const CODEX_PROMPT_SUGGESTION_PROMPT = + "Predict the next message the user will send in this conversation. Reply with only that message, written as the user would type it, in under 20 words. Do not use tools. If there is no clear next step, reply with nothing."; const CODEX_CLIENT_CAPABILITIES = { experimentalApi: true, optOutNotificationMethods: ["turn/diff/updated"], @@ -2016,6 +2021,43 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + const promptSuggestionsEnabled = input.promptSuggestions ?? Effect.succeed(false); + // Best effort: older Codex lacks thread/sample, and a sample fails once the user sends again. + // Delegated threads share this session but have no one typing, so they never sample. + const samplePromptSuggestion = Effect.fn("CodexAdapterV2.samplePromptSuggestion")( + function* ( + context: ActiveCodexTurnContext, + nativeThreadId: string, + nativeTurnId: string, + ) { + if ( + isDelegatedAppThread(context.input.appThread) || + !(yield* promptSuggestionsEnabled) + ) { + return; + } + const response = yield* client.raw.request("thread/sample", { + threadId: nativeThreadId, + expectedTurnId: nativeTurnId, + prompt: CODEX_PROMPT_SUGGESTION_PROMPT, + effort: "low", + }); + const suggestion = + (yield* decodeCodexThreadSampleResponse(response)).text?.trim() ?? ""; + if (suggestion.length === 0) return; + yield* emitProviderEvent({ + type: "prompt_suggestion.updated", + driver: CODEX_PROVIDER, + threadId: context.projectionThreadId, + runId: context.input.runId, + text: suggestion, + }); + }, + Effect.catchCause((cause) => + Effect.logDebug("orchestration-v2.codex-prompt-suggestion-failed", { cause }), + ), + ); + const emitImageWorkState = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced(function* (context: ActiveCodexTurnContext, pending: boolean) { @@ -6038,6 +6080,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); yield* emitRootTerminal(hold.context, { ...hold.event, status }); yield* Deferred.succeed(hold.next, undefined); + const nativeThreadId = hold.context.providerThread.nativeThreadRef?.nativeId; + if (status === "completed" && nativeThreadId != null) { + yield* samplePromptSuggestion( + hold.context, + nativeThreadId, + hold.context.nativeTurnId, + ).pipe(Effect.forkIn(scope)); + } }); const releaseGoalHold = Effect.fnUntraced(function* ( @@ -6369,6 +6419,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const retainSettledContext = input.status === "completed" && (yield* turnHasRetainedBackgroundWork(input.nativeTurnId)); + // A held goal predicts when settleGoalHold completes it. + if ( + input.status === "completed" && + input.context.subagent === null && + !input.continueRun && + !holdsForGoal && + nativeThreadId != null + ) { + yield* samplePromptSuggestion( + input.context, + nativeThreadId, + input.nativeTurnId, + ).pipe(Effect.forkIn(scope)); + } if (retainSettledContext) { yield* Ref.update(settledTurns, (current) => { const updated = new Map(current); diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 2329f2aa20cf0..3be77bba74c7c 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -21,6 +21,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as CheckpointService from "./CheckpointService.ts"; @@ -113,6 +114,7 @@ function layerExecutorFor(input: { detach: () => record("detach"), otherWorkingThreads: () => Effect.succeed([]), releaseUnlessBusy: () => Effect.succeed(true), + subscribePromptSuggestion: () => Stream.empty, }), ), Layer.succeed( diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index f27580d909aff..40a858bbeb79f 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -619,7 +619,8 @@ export const layer: Layer.Layer< case "background_work.updated": return []; case "command_output.delta": - // Live output is routed to the command output hub before ingestion. + case "prompt_suggestion.updated": + // The session manager serves these to clients before ingestion. return []; case "turn.terminal": const dismissed = yield* dismissNativeUserInputs(input, input.event.providerTurnId); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 7c0332059cefd..a8e2e84ba74c5 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -8,6 +8,7 @@ import { type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2DomainEvent, + type OrchestrationV2PromptSuggestion, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, @@ -2744,6 +2745,63 @@ it.effect("ProviderSessionManagerV2 terminal detach revokes the thread's MCP cre }), ); +it.effect( + "ProviderSessionManagerV2 stops serving prompt suggestions once a thread is deleted", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-suggestion"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const created = yield* makeThreadCreatedEvent({ idAllocator, threadId, now }); + yield* eventSink.write({ events: [created] }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const suggest = (text: string) => + Queue.offer(queue!, { + type: "prompt_suggestion.updated", + driver: CODEX_DRIVER, + threadId, + runId, + text, + }); + const received = yield* Queue.unbounded(); + yield* manager.subscribePromptSuggestion(threadId).pipe( + Stream.runForEach((suggestion) => Queue.offer(received, suggestion)), + Effect.forkScoped, + ); + assert.isNull(yield* Queue.take(received)); + + yield* suggest("add tests"); + assert.deepEqual(yield* Queue.take(received), { runId, text: "add tests" }); + + yield* eventSink.write({ + events: [ + { + ...created, + id: yield* idAllocator.allocate.event({ threadId }), + type: "thread.deleted", + payload: { ...created.payload, deletedAt: now }, + }, + ], + }); + // A late Codex sample can land after deletion; the open subscription must drop it. + yield* suggest("ship it"); + assert.isNull(yield* Queue.take(received)); + }); + + yield* effect.pipe(Effect.scoped, Effect.provide(layerTest({ state, idleTimeoutMs: 1_000 }))); + }), +); + const openSessionWithBackgroundCommand = (fixtureName: string) => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index b36119eb581d9..62fdda69e5111 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -6,6 +6,7 @@ import { ModelSelection, OrchestrationV2DomainEvent, OrchestrationV2ProviderSession, + type OrchestrationV2PromptSuggestion, type OrchestrationV2ProviderThread, OrchestrationV2RuntimeRequest, ProviderInstanceId, @@ -32,6 +33,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { normalizeModelMetricLabel } from "../observability/Attributes.ts"; import { @@ -64,6 +66,8 @@ const busyTurnPrefix = (providerThreadId: ProviderThreadId) => `${providerThread const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => `${busyTurnPrefix(providerThreadId)}${runOrdinal}`; const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; +/** Suggestions outlive their sessions, so keep only the most recently updated threads. */ +const MAX_PROMPT_SUGGESTIONS = 256; const WORKING_RUN_STATUSES: ReadonlySet = new Set([ "preparing", "starting", @@ -208,6 +212,10 @@ export interface ProviderSessionManagerV2Shape { readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; }) => Effect.Effect>; + /** The thread's latest predicted next prompt, then each replacement. */ + readonly subscribePromptSuggestion: ( + threadId: ThreadId, + ) => Stream.Stream; } export class ProviderSessionManagerV2 extends Context.Service< @@ -345,10 +353,21 @@ export const layerWithOptions = ( const projectService = yield* Effect.serviceOption(ProjectService.ProjectService); const eventSink = yield* EventSink.EventSinkV2; const commandOutput = yield* CommandOutputHub.CommandOutputHub; + const promptSuggestionStore = yield* SubscriptionRef.make< + ReadonlyMap> + >(new Map()); const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const agentScope = yield* AgentScope; + // Labs flag, read live: Codex per turn (its shared session outlives a toggle), Claude per query. + // Unreadable settings keep it off. + const promptSuggestionsEnabled = Option.isNone(serverSettings) + ? Effect.succeed(false) + : serverSettings.value.getSettings.pipe( + Effect.map((settings) => settings.promptSuggestionsEnabled), + Effect.orElseSucceed(() => false), + ); const agentAccessSettings = Effect.fn("ProviderSessionManagerV2.agentAccessSettings")( function* (threadId: ThreadId) { if (Option.isNone(serverSettings)) return { browser: true, device: false }; @@ -2175,6 +2194,18 @@ export const layerWithOptions = ( if (event.type === "command_output.delta") { return commandOutput.append(event); } + if (event.type === "prompt_suggestion.updated") { + return SubscriptionRef.update(promptSuggestionStore, (current) => { + const next = new Map(current); + next.delete(event.threadId); + next.set(event.threadId, { runId: event.runId, text: event.text }); + for (const threadId of next.keys()) { + if (next.size <= MAX_PROMPT_SUGGESTIONS) break; + next.delete(threadId); + } + return next; + }); + } const releaseLiveOutput = event.type === "turn_item.updated" && event.turnItem.type === "command_execution" && @@ -2418,6 +2449,7 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, + promptSuggestions: promptSuggestionsEnabled, ...(input.resumeFromSession === undefined ? {} : { resumeFromSession: input.resumeFromSession }), @@ -2795,6 +2827,21 @@ export const layerWithOptions = ( ), ), ), + // Stored suggestions can outlive a deleted thread, so check the thread before serving one. + subscribePromptSuggestion: (threadId) => + SubscriptionRef.changes(promptSuggestionStore).pipe( + Stream.map((suggestions) => suggestions.get(threadId) ?? null), + Stream.changes, + Stream.mapEffect((suggestion) => + suggestion === null + ? Effect.succeed(null) + : projectionStore.getThread(threadId).pipe( + Effect.map((thread) => (thread.deletedAt === null ? suggestion : null)), + Effect.orElseSucceed(() => null), + ), + ), + Stream.changes, + ), } satisfies ProviderSessionManagerV2Shape); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 15800ab273b89..8b30a961c099d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -294,6 +294,7 @@ it.effect( detach: () => Effect.void, otherWorkingThreads: () => Effect.succeed([]), releaseUnlessBusy: () => Effect.succeed(true), + subscribePromptSuggestion: () => Stream.empty, }), ); const layerControl = ProviderTurnControlService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 304eeaba43421..66c0d2143e9aa 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -512,7 +512,8 @@ export function routeProviderEvent( case "background_work.updated": return [state.ownedProviderTurns.has(event.providerTurnId), state]; case "command_output.delta": - // The session manager hands live output to the command output hub; runs never see it. + case "prompt_suggestion.updated": + // The session manager serves these to clients directly; runs never see them. return [false, state]; case "turn.terminal": return event.providerTurnId === state.rootProviderTurnId diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cf2606c3a1305..1b6835ef993f8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2346,6 +2346,8 @@ export const layerWsRpc = ( Effect.as(commandOutputHub.subscribe(input)), ), ), + [ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion]: (input) => + providerSessionManager.subscribePromptSuggestion(input.threadId), [WS_METHODS.scheduledTasksSubscribe]: (_input) => scheduledTasks.subscribeList().pipe(Stream.map(withVisibleWebhookUrls)), [WS_METHODS.scheduledTasksUpsert]: (input) => scheduledTasks.upsert(input), diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 8c4698210b1d5..92047bc74180e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -46,6 +46,7 @@ import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, type RefObject, } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -161,6 +162,8 @@ export interface ComposerPromptEditorProps { skills: ReadonlyArray; disabled: boolean; placeholder: string; + /** Rendered after the placeholder text, such as a key hint. */ + placeholderSuffix?: ReactNode; ariaLabel?: string | undefined; suggestionListId?: string | undefined; activeSuggestionId?: string | undefined; @@ -876,6 +879,7 @@ function ComposerPromptEditorTiptapInner( containerClassName, className, placeholderClassName, + placeholderSuffix, onChange, onVisibleSelectionChange, onCommandKeyDown, @@ -1786,6 +1790,7 @@ function ComposerPromptEditorTiptapInner( )} > {placeholder} + {placeholderSuffix} ) : null} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e9040012d684a..b1faa65363c77 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -48,6 +48,7 @@ import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Schema from "effect/Schema"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { splitTransferredFiles } from "./workspaceFileDrop"; @@ -244,6 +245,7 @@ import { resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { Kbd } from "../ui/kbd"; import { useSoftwareKeyboardOpen } from "../../hooks/useSoftwareKeyboardOpen"; import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; @@ -264,6 +266,8 @@ import { } from "./preparingComposerImages"; import { ComposerControlsBar } from "./ComposerControlsBar"; import { composerCommandKeyHandler } from "./composerCommandKey"; +import { usePromptSuggestion } from "./usePromptSuggestion"; +import { useFreshSessionArmed } from "./freshSessionArms"; function consumePasteAsTextShortcut(deadline: { current: number }): boolean { const armed = Date.now() <= deadline.current; @@ -2226,6 +2230,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const expandMobileComposerStable = useStableCallback(expandMobileComposer); + // Escape dismisses the suggestion so Tab moves focus again. + const { suggestion: promptSuggestion, dismiss: dismissPromptSuggestion } = usePromptSuggestion( + environmentId, + activeThread, + ); + // After /clear the next message starts a fresh session that cannot see what the suggestion refers to. + const freshSessionArmed = useFreshSessionArmed(scopedThreadKey(routeThreadRef)); + // Offered only where the default placeholder would show, so it never hides a pending question. + const offeredPromptSuggestion = + phase !== "running" && + !isSendBusy && + !freshSessionArmed && + !speechInput.freezesEditor && + composerContextRecords.size === 0 && + !isComposerApprovalState && + !activePendingProgress && + !projectSelectionRequired && + !showProviderUnavailable + ? promptSuggestion + : null; + const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem?: boolean) => composerCommandKeyHandler({ keybindings, @@ -2261,6 +2286,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) readComposerSnapshot, applyPromptReplacement, submitComposer, + promptSuggestion: offeredPromptSuggestion, + dismissPromptSuggestion, })(key, event, isTaskItem); const { @@ -3429,7 +3456,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Choose a project above to start a thread" : showProviderUnavailable ? "Enable a provider in Settings to send a message" - : DEFAULT_COMPOSER_PLACEHOLDER + : (offeredPromptSuggestion ?? DEFAULT_COMPOSER_PLACEHOLDER) + } + placeholderSuffix={ + offeredPromptSuggestion !== null ? ( + + ⇥ Tab + + ) : undefined } disabled={ projectSelectionRequired || diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index 4a5c0e9abc0bc..ac5bba3475542 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -30,7 +30,7 @@ import type { Dispatch, RefObject, SetStateAction } from "react"; /** * The composer's key handling inside the editor: send shortcuts, the slash/mention menu, - * prompt history on ArrowUp/ArrowDown, plan-mode toggle, and list continuation. + * prompt history on ArrowUp/ArrowDown, plan-mode toggle, prompt suggestions on Tab, and list continuation. * Returns the editor's key handler; it reads the inputs captured by the render that built it. */ export function composerCommandKeyHandler(input: { @@ -82,6 +82,9 @@ export function composerCommandKeyHandler(input: { dispatchMode: ComposerDispatchMode, submissionIntent: ComposerSubmissionIntent, ) => void; + /** Predicted next prompt that Tab fills into an empty composer; Escape dismisses it. */ + readonly promptSuggestion: string | null; + readonly dismissPromptSuggestion: () => void; }) { const { keybindings, @@ -117,6 +120,8 @@ export function composerCommandKeyHandler(input: { readComposerSnapshot, applyPromptReplacement, submitComposer, + promptSuggestion, + dismissPromptSuggestion, } = input; // The composer persists across threads. A recall from thread A must not @@ -211,7 +216,12 @@ export function composerCommandKeyHandler(input: { const menuIsActive = !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); if (key === "Escape") { - if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; + if (event.isComposing || event.keyCode === 229) return false; + if (!menuIsActive && promptSuggestion !== null && promptRef.current.length === 0) { + dismissPromptSuggestion(); + return true; + } + if (!menuIsActive) return false; dismissComposerTrigger(trigger); composerMenuOpenRef.current = false; return true; @@ -232,6 +242,21 @@ export function composerCommandKeyHandler(input: { return true; } } + if ( + key === "Tab" && + promptSuggestion !== null && + promptRef.current.length === 0 && + !menuIsActive && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.isComposing && + event.keyCode !== 229 + ) { + // The typing guard must see this draft, so an approval that lands next is not answered with it. + return applyPromptReplacement(0, 0, promptSuggestion); + } if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) { return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); } diff --git a/apps/web/src/components/chat/usePromptSuggestion.ts b/apps/web/src/components/chat/usePromptSuggestion.ts new file mode 100644 index 0000000000000..9002c368d32b5 --- /dev/null +++ b/apps/web/src/components/chat/usePromptSuggestion.ts @@ -0,0 +1,55 @@ +import type { EnvironmentId, RunId } from "@t3tools/contracts"; +import { useSyncExternalStore } from "react"; + +import type { Thread } from "../../types"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useServerConfigs } from "../../state/entities"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { useEnvironmentQuery } from "../../state/query"; + +// Dismissed suggestions, keyed by environment and run, shared by every mounted composer and kept +// across remounts. Run ids derive from thread ids, which can repeat across environments. +let dismissedKeys: ReadonlySet = new Set(); +const listeners = new Set<() => void>(); +const subscribeDismissed = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; +const readDismissed = () => dismissedKeys; +const dismissKey = (environmentId: EnvironmentId, runId: RunId) => `${environmentId}\u0000${runId}`; + +/** + * The provider's predicted next prompt, while the run that produced it is the thread's latest + * completed run and Escape has not dismissed it. + */ +export function usePromptSuggestion( + environmentId: EnvironmentId, + thread: Thread | undefined, +): { readonly suggestion: string | null; readonly dismiss: () => void } { + // Older servers decode the missing setting as false, so true also proves the method exists. + const enabled = useServerConfigs().get(environmentId)?.settings.promptSuggestionsEnabled === true; + // Tab is the only way to accept one, so skip devices with no mouse or trackpad at all. + const hasFinePointer = useMediaQuery("(any-pointer: fine)"); + const { data } = useEnvironmentQuery( + // A local draft has no run, and so no suggestion, until its first send. + enabled && hasFinePointer && thread?.latestRun + ? orchestrationEnvironment.v2.promptSuggestion({ + environmentId, + input: { threadId: thread.id }, + }) + : null, + ); + const dismissed = useSyncExternalStore(subscribeDismissed, readDismissed); + const latestRun = thread?.latestRun; + const offered = + data && latestRun?.runId === data.runId && latestRun.status === "completed" ? data : null; + return { + suggestion: + offered && !dismissed.has(dismissKey(environmentId, offered.runId)) ? offered.text : null, + dismiss: () => { + if (offered === null) return; + dismissedKeys = new Set(dismissedKeys).add(dismissKey(environmentId, offered.runId)); + for (const listener of listeners) listener(); + }, + }; +} diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 9c71a21c265f5..38be7d611518e 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -22,6 +22,7 @@ export function LabsSettingsPanel() { {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -166,6 +167,29 @@ function LinkedEnvironmentsRow() { ); } +function PromptSuggestionsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ promptSuggestionsEnabled: Boolean(checked) }) + } + aria-label="Suggest next prompt" + /> + } + /> + ); +} + function SimlockHostsRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f33ccc35a6bad..eaabdefd3881b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -492,6 +492,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment", searchTerms: ["beta link another machine vps peer agents work there delegate"], }, + { + id: "prompt-suggestions-beta", + title: "Suggest next prompt", + to: "/settings/labs", + scope: "environment", + searchTerms: ["beta tab autocomplete predict next message claude codex ghost text"], + }, { id: "simlock-hosts", title: "Simlock device hosts (alpha)", diff --git a/apps/web/src/components/ui/kbd.tsx b/apps/web/src/components/ui/kbd.tsx index 7975b4dd14767..166f1b1854e5e 100644 --- a/apps/web/src/components/ui/kbd.tsx +++ b/apps/web/src/components/ui/kbd.tsx @@ -2,11 +2,21 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; -function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { +function Kbd({ + className, + variant = "default", + ...props +}: React.ComponentProps<"kbd"> & { + /** `outline` draws a physical key, so the hint stays distinct inside surrounding text. */ + variant?: "default" | "outline"; +}) { return ( Labs. After a Claude or Codex turn completes, the +empty composer shows a predicted next message. Press `Tab` to fill it in, then edit or send it, or +press `Escape` to dismiss it. Each suggestion is one extra request that reuses the turn's prompt +cache, so it uses a little of your plan's usage. Claude skips suggestions on the first turn of a +thread and near usage limits. Codex needs version 0.165 or later; if T3 Code manages +Codex, update it in Settings > Providers. Codex follows a change to the setting at once; open Claude +threads follow it after they restart. Suggestions show on web and desktop. + ## Edit an earlier prompt On web and desktop, choose **Edit from here** beneath a sent message to rewind diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index d3a49c2aa58b3..fbdfe6749abb4 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -54,6 +54,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread | typeof ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput + | typeof ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion | typeof ORCHESTRATION_V2_WS_METHODS.searchThreadStream | typeof WS_METHODS.subscribeAuthAccess | typeof WS_METHODS.subscribeServerConfig diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index af89357f0f1ed..c33ed77c45bc1 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -152,6 +152,12 @@ export function createOrchestrationEnvironmentAtoms( Stream.filter((view) => view !== EMPTY_COMMAND_OUTPUT), ), }), + // The open thread's predicted next prompt, while its composer is mounted. + promptSuggestion: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:orchestration-v2:prompt-suggestion", + tag: ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion, + idleTtlMs: 0, + }), }, turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:turn-diff", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index eb0e9d43190dc..881ad7e4057cc 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -517,6 +517,13 @@ export const OrchestrationV2AppThread = Schema.Struct({ }); export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type; +/** A thread another agent drives: a local subagent or a task delegated from a linked environment. */ +export const isDelegatedAppThread = ( + thread: Pick, +): boolean => + // MCP-launched threads store `delegatedFrom: null` at runtime, so check both absent shapes. + thread.lineage.relationshipToParent === "subagent" || thread.delegatedFrom != null; + /** Provider-native children cannot receive messages; T3 delegate_task children can. */ export function isProviderNativeSubagentThread( thread: Pick, @@ -3617,6 +3624,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", subscribeCommandOutput: "orchestration.subscribeCommandOutput", + subscribePromptSuggestion: "orchestration.subscribePromptSuggestion", } as const; export const OrchestrationV2SubscribeCommandOutputInput = Schema.Struct({ @@ -3646,6 +3654,16 @@ export class OrchestrationV2CommandOutputError extends Schema.TaggedError; readonly resumeFromSession?: OrchestrationV2ProviderSession; readonly initialNativeThreadId?: string; readonly initialProviderItemIdentityVersion?: 2;