diff --git a/apps/server/src/provider/Layers/CodexDynamicTools.test.ts b/apps/server/src/provider/Layers/CodexDynamicTools.test.ts new file mode 100644 index 00000000000..9a6d5a49222 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexDynamicTools.test.ts @@ -0,0 +1,207 @@ +import * as NodeAssert from "node:assert/strict"; + +import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { describe } from "vite-plus/test"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { + handleT3CodexDynamicToolCall, + makeT3CodexDynamicToolWaitRegistry, + T3_CODEX_DYNAMIC_TOOLS, + T3_CODEX_DYNAMIC_TOOL_NAMESPACE, + T3_CODEX_WAIT_MAX_DURATION_MS, + T3_CODEX_WAIT_MIN_DURATION_MS, + T3_CODEX_WAIT_TOOL_NAME, +} from "./CodexDynamicTools.ts"; + +function waitCall( + arguments_: unknown, + overrides: Partial = {}, +): EffectCodexSchema.DynamicToolCallParams { + return { + arguments: arguments_, + callId: "wait-call-1", + namespace: T3_CODEX_DYNAMIC_TOOL_NAMESPACE, + threadId: "provider-thread-1", + tool: T3_CODEX_WAIT_TOOL_NAME, + turnId: "provider-turn-1", + ...overrides, + }; +} + +describe("T3 Codex dynamic tools", () => { + it("publishes a bounded namespaced wait tool", () => { + const namespace = T3_CODEX_DYNAMIC_TOOLS[0]; + const wait = namespace?.tools[0]; + + NodeAssert.equal(namespace?.type, "namespace"); + NodeAssert.equal(namespace?.name, T3_CODEX_DYNAMIC_TOOL_NAMESPACE); + NodeAssert.equal(wait?.type, "function"); + NodeAssert.equal(wait?.name, T3_CODEX_WAIT_TOOL_NAME); + NodeAssert.deepStrictEqual(wait?.inputSchema, { + type: "object", + properties: { + durationMs: { + type: "integer", + minimum: T3_CODEX_WAIT_MIN_DURATION_MS, + maximum: T3_CODEX_WAIT_MAX_DURATION_MS, + description: "How long T3 should wait, in milliseconds.", + }, + reason: { + type: "string", + maxLength: 500, + description: "Optional short explanation of the external work being awaited.", + }, + }, + required: ["durationMs"], + additionalProperties: false, + }); + }); + + it.effect("does not complete before the requested duration", () => + Effect.gen(function* () { + const fiber = yield* handleT3CodexDynamicToolCall( + waitCall({ durationMs: T3_CODEX_WAIT_MIN_DURATION_MS, reason: "CI is running" }), + ).pipe(Effect.forkChild); + + yield* TestClock.adjust(T3_CODEX_WAIT_MIN_DURATION_MS - 1); + NodeAssert.equal(fiber.pollUnsafe(), undefined); + + yield* TestClock.adjust(1); + NodeAssert.deepStrictEqual(yield* Fiber.join(fiber), { + success: true, + contentItems: [ + { + type: "inputText", + text: `Wait completed after ${T3_CODEX_WAIT_MIN_DURATION_MS} ms.`, + }, + ], + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("cancels an active wait", () => + Effect.gen(function* () { + const cancelled = yield* Deferred.make(); + const fiber = yield* handleT3CodexDynamicToolCall( + waitCall({ durationMs: T3_CODEX_WAIT_MAX_DURATION_MS }), + Deferred.await(cancelled), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(cancelled, undefined); + + NodeAssert.deepStrictEqual(yield* Fiber.join(fiber), { + success: false, + contentItems: [ + { + type: "inputText", + text: "Wait cancelled because the turn was interrupted or the session closed.", + }, + ], + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("cancels waits by turn and cancels all remaining waits on shutdown", () => + Effect.gen(function* () { + const registry = yield* makeT3CodexDynamicToolWaitRegistry(); + const first = yield* registry + .handle( + waitCall( + { durationMs: T3_CODEX_WAIT_MAX_DURATION_MS }, + { callId: "wait-call-1", turnId: "provider-turn-1" }, + ), + ) + .pipe(Effect.forkChild); + const second = yield* registry + .handle( + waitCall( + { durationMs: T3_CODEX_WAIT_MAX_DURATION_MS }, + { callId: "wait-call-2", turnId: "provider-turn-2" }, + ), + ) + .pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* registry.cancelTurn("provider-turn-1"); + + NodeAssert.equal((yield* Fiber.join(first)).success, false); + NodeAssert.equal(second.pollUnsafe(), undefined); + + yield* registry.cancelAll; + NodeAssert.equal((yield* Fiber.join(second)).success, false); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("immediately cancels waits registered after their turn was interrupted", () => + Effect.gen(function* () { + const registry = yield* makeT3CodexDynamicToolWaitRegistry(); + yield* registry.cancelTurn("provider-turn-1"); + + const response = yield* registry.handle( + waitCall({ durationMs: T3_CODEX_WAIT_MAX_DURATION_MS }, { turnId: "provider-turn-1" }), + ); + + NodeAssert.equal(response.success, false); + NodeAssert.match( + response.contentItems[0]?.type === "inputText" ? response.contentItems[0].text : "", + /Wait cancelled/, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("immediately cancels waits registered after session shutdown", () => + Effect.gen(function* () { + const registry = yield* makeT3CodexDynamicToolWaitRegistry(); + yield* registry.cancelAll; + + const response = yield* registry.handle( + waitCall({ durationMs: T3_CODEX_WAIT_MAX_DURATION_MS }), + ); + + NodeAssert.equal(response.success, false); + NodeAssert.match( + response.contentItems[0]?.type === "inputText" ? response.contentItems[0].text : "", + /Wait cancelled/, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rejects malformed and out-of-range arguments without sleeping", () => + Effect.gen(function* () { + for (const arguments_ of [ + {}, + { durationMs: T3_CODEX_WAIT_MIN_DURATION_MS - 1 }, + { durationMs: T3_CODEX_WAIT_MAX_DURATION_MS + 1 }, + { durationMs: 1_000.5 }, + { durationMs: "1000" }, + ]) { + const response = yield* handleT3CodexDynamicToolCall(waitCall(arguments_)); + NodeAssert.equal(response.success, false); + NodeAssert.match( + response.contentItems[0]?.type === "inputText" ? response.contentItems[0].text : "", + /Invalid wait arguments/, + ); + } + }), + ); + + it.effect("rejects calls outside the T3 namespace", () => + Effect.gen(function* () { + const response = yield* handleT3CodexDynamicToolCall( + waitCall({ durationMs: T3_CODEX_WAIT_MIN_DURATION_MS }, { namespace: "other" }), + ); + + NodeAssert.equal(response.success, false); + NodeAssert.match( + response.contentItems[0]?.type === "inputText" ? response.contentItems[0].text : "", + /Unknown T3 Code dynamic tool/, + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexDynamicTools.ts b/apps/server/src/provider/Layers/CodexDynamicTools.ts new file mode 100644 index 00000000000..4ccc8ca8d96 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexDynamicTools.ts @@ -0,0 +1,173 @@ +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +export const T3_CODEX_DYNAMIC_TOOL_NAMESPACE = "t3"; +export const T3_CODEX_WAIT_TOOL_NAME = "wait"; +export const T3_CODEX_WAIT_MIN_DURATION_MS = 1_000; +export const T3_CODEX_WAIT_MAX_DURATION_MS = 3_600_000; + +const T3CodexWaitArguments = Schema.Struct({ + durationMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(T3_CODEX_WAIT_MIN_DURATION_MS)).check( + Schema.isLessThanOrEqualTo(T3_CODEX_WAIT_MAX_DURATION_MS), + ), + reason: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(500))), +}); +const decodeT3CodexWaitArguments = Schema.decodeUnknownEffect(T3CodexWaitArguments); + +export const T3_CODEX_DYNAMIC_TOOLS = [ + { + type: "namespace", + name: T3_CODEX_DYNAMIC_TOOL_NAMESPACE, + description: "T3 Code host lifecycle tools that can pause work without model polling.", + tools: [ + { + type: "function", + name: T3_CODEX_WAIT_TOOL_NAME, + description: + "Wait inside T3 Code without using model inference. Use this for an intentional idle period while external work is running, then check the work once after the wait completes. The wait only lasts while this live T3 provider session remains connected; it is cancelled if the turn is interrupted or the session closes.", + inputSchema: { + type: "object", + properties: { + durationMs: { + type: "integer", + minimum: T3_CODEX_WAIT_MIN_DURATION_MS, + maximum: T3_CODEX_WAIT_MAX_DURATION_MS, + description: "How long T3 should wait, in milliseconds.", + }, + reason: { + type: "string", + maxLength: 500, + description: "Optional short explanation of the external work being awaited.", + }, + }, + required: ["durationMs"], + additionalProperties: false, + }, + }, + ], + }, +] as const satisfies ReadonlyArray; + +function textResponse(success: boolean, text: string): EffectCodexSchema.DynamicToolCallResponse { + return { + success, + contentItems: [{ type: "inputText", text }], + }; +} + +export const handleT3CodexDynamicToolCall = Effect.fn("handleT3CodexDynamicToolCall")(function* ( + payload: EffectCodexSchema.DynamicToolCallParams, + cancelled: Effect.Effect = Effect.never, +): Effect.fn.Return { + if ( + payload.namespace !== T3_CODEX_DYNAMIC_TOOL_NAMESPACE || + payload.tool !== T3_CODEX_WAIT_TOOL_NAME + ) { + return textResponse( + false, + `Unknown T3 Code dynamic tool: ${payload.namespace ?? ""}.${payload.tool}`, + ); + } + + const decoded = yield* Effect.result(decodeT3CodexWaitArguments(payload.arguments)); + if (Result.isFailure(decoded)) { + return textResponse( + false, + `Invalid wait arguments. durationMs must be an integer from ${T3_CODEX_WAIT_MIN_DURATION_MS} through ${T3_CODEX_WAIT_MAX_DURATION_MS}.`, + ); + } + + const outcome = yield* Effect.sleep(Duration.millis(decoded.success.durationMs)).pipe( + Effect.as("elapsed" as const), + Effect.raceFirst(cancelled.pipe(Effect.as("cancelled" as const))), + ); + + return outcome === "elapsed" + ? textResponse(true, `Wait completed after ${decoded.success.durationMs} ms.`) + : textResponse(false, "Wait cancelled because the turn was interrupted or the session closed."); +}); + +interface PendingWait { + readonly turnId: string; + readonly cancelled: Deferred.Deferred; +} + +interface WaitRegistryState { + readonly pending: Map; + readonly cancelledTurnIds: Set; + readonly closed: boolean; +} + +export interface T3CodexDynamicToolWaitRegistry { + readonly handle: ( + payload: EffectCodexSchema.DynamicToolCallParams, + ) => Effect.Effect; + readonly cancelTurn: (turnId: string) => Effect.Effect; + readonly cancelAll: Effect.Effect; +} + +export const makeT3CodexDynamicToolWaitRegistry = Effect.fn("makeT3CodexDynamicToolWaitRegistry")( + function* (): Effect.fn.Return { + const stateRef = yield* Ref.make({ + pending: new Map(), + cancelledTurnIds: new Set(), + closed: false, + }); + + const completeCancellations = (pendingWaits: ReadonlyArray) => + Effect.forEach( + pendingWaits, + (pending) => Deferred.succeed(pending.cancelled, undefined).pipe(Effect.ignore), + { discard: true }, + ); + + return { + handle: (payload) => + Effect.gen(function* () { + const cancelled = yield* Deferred.make(); + const registered = yield* Ref.modify(stateRef, (current) => { + if (current.closed || current.cancelledTurnIds.has(payload.turnId)) { + return [false, current] as const; + } + const pending = new Map(current.pending); + pending.set(payload.callId, { turnId: payload.turnId, cancelled }); + return [true, { ...current, pending }] as const; + }); + + return yield* handleT3CodexDynamicToolCall( + payload, + registered ? Deferred.await(cancelled) : Effect.void, + ).pipe( + Effect.ensuring( + Ref.update(stateRef, (current) => { + if (!current.pending.has(payload.callId)) { + return current; + } + const pending = new Map(current.pending); + pending.delete(payload.callId); + return { ...current, pending }; + }), + ), + ); + }), + cancelTurn: (turnId) => + Ref.modify(stateRef, (current) => { + const cancelledTurnIds = new Set(current.cancelledTurnIds); + cancelledTurnIds.add(turnId); + const pending = Array.from(current.pending.values()).filter( + (wait) => wait.turnId === turnId, + ); + return [pending, { ...current, cancelledTurnIds }] as const; + }).pipe(Effect.flatMap(completeCancellations)), + cancelAll: Ref.modify(stateRef, (current) => [ + Array.from(current.pending.values()), + { ...current, closed: true }, + ]).pipe(Effect.flatMap(completeCancellations)), + }; + }, +); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 119fa36303a..2f89fd9cf35 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -14,6 +14,7 @@ import { CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; +import { T3_CODEX_DYNAMIC_TOOLS } from "./CodexDynamicTools.ts"; import { buildTurnStartParams, hasConfiguredMcpServer, @@ -48,18 +49,22 @@ function makeThreadOpenResponse( modelProvider: "openai", approvalPolicy: "never", approvalsReviewer: "user", - sandbox: { type: "danger-full-access" }, + sandbox: { type: "dangerFullAccess" }, thread: { id: threadId, - createdAt: "2026-04-18T00:00:00.000Z", - source: { session: "cli" }, + cliVersion: "0.0.0-test", + createdAt: 1_776_470_400, + cwd: "/tmp/project", + ephemeral: false, + modelProvider: "openai", + preview: "", + sessionId: "session-1", + source: "appServer", turns: [], - status: { - state: "idle", - activeFlags: [], - }, + status: { type: "idle" }, + updatedAt: 1_776_470_400, }, - } as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"]; + }; } describe("buildTurnStartParams", () => { @@ -365,11 +370,55 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + it.effect("starts a fresh thread with the T3 dynamic tools", () => + Effect.gen(function* () { + const calls: Array<{ method: string; payload: unknown }> = []; + const client = { + raw: { + request: (method: string, payload?: unknown) => { + calls.push({ method, payload }); + return Effect.succeed(makeThreadOpenResponse("fresh-thread")); + }, + }, + request: ( + _method: M, + _payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => Effect.die("typed request path should not be used for a fresh thread"), + }; + + const opened = yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: undefined, + }); + + NodeAssert.equal(opened.thread.id, "fresh-thread"); + NodeAssert.equal(calls.length, 1); + const freshStart = calls[0]; + NodeAssert.ok(freshStart); + NodeAssert.equal(freshStart.method, "thread/start"); + NodeAssert.deepStrictEqual( + (freshStart.payload as { readonly dynamicTools?: unknown }).dynamicTools, + T3_CODEX_DYNAMIC_TOOLS, + ); + }), + ); + it.effect("falls back to thread/start when resume fails recoverably", () => Effect.gen(function* () { const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; const started = makeThreadOpenResponse("fresh-thread"); const client = { + raw: { + request: (method: string, payload?: unknown) => { + calls.push({ method: method as "thread/start", payload }); + return Effect.succeed(started); + }, + }, request: ( method: M, payload: CodexRpc.ClientRequestParamsByMethod[M], @@ -402,12 +451,22 @@ describe("openCodexThread", () => { calls.map((call) => call.method), ["thread/resume", "thread/start"], ); + const fallbackStart = calls[1]; + NodeAssert.ok(fallbackStart); + NodeAssert.deepStrictEqual( + (fallbackStart.payload as { readonly dynamicTools?: unknown }).dynamicTools, + T3_CODEX_DYNAMIC_TOOLS, + ); }), ); it.effect("propagates non-recoverable resume failures", () => Effect.gen(function* () { const client = { + raw: { + request: (_method: string, _payload?: unknown) => + Effect.succeed(makeThreadOpenResponse("fresh-thread")), + }, request: ( method: M, _payload: CodexRpc.ClientRequestParamsByMethod[M], diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5a81e915e34..4fd1cc847b7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -36,10 +36,14 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { makeT3CodexDynamicToolWaitRegistry, T3_CODEX_DYNAMIC_TOOLS } from "./CodexDynamicTools.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); +const decodeV2ThreadStartResponse = Schema.decodeUnknownEffect( + EffectCodexSchema.V2ThreadStartResponse, +); const PROVIDER = ProviderDriverKind.make("codex"); @@ -285,12 +289,16 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { } } +type CodexThreadStartParamsWithDynamicTools = EffectCodexSchema.V2ThreadStartParams & { + readonly dynamicTools: ReadonlyArray; +}; + function buildThreadStartParams(input: { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model: string | undefined; readonly serviceTier: CodexServiceTier | undefined; -}): EffectCodexSchema.V2ThreadStartParams { +}): CodexThreadStartParamsWithDynamicTools { const config = runtimeModeToThreadConfig(input.runtimeMode); return { cwd: input.cwd, @@ -298,6 +306,7 @@ function buildThreadStartParams(input: { sandbox: config.sandbox, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + dynamicTools: T3_CODEX_DYNAMIC_TOOLS, }; } @@ -433,12 +442,39 @@ type CodexThreadOpenResponse = type CodexThreadOpenMethod = "thread/start" | "thread/resume"; interface CodexThreadOpenClient { + readonly raw: { + readonly request: ( + method: string, + payload?: unknown, + ) => Effect.Effect; + }; readonly request: ( method: M, payload: CodexRpc.ClientRequestParamsByMethod[M], ) => Effect.Effect; } +function startCodexThread( + client: CodexThreadOpenClient, + params: CodexThreadStartParamsWithDynamicTools, +): Effect.Effect< + CodexRpc.ClientRequestResponsesByMethod["thread/start"], + CodexErrors.CodexAppServerError +> { + return client.raw.request("thread/start", params).pipe( + Effect.flatMap(decodeV2ThreadStartResponse), + Effect.mapError((error) => + Schema.isSchemaError(error) + ? CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "thread/start" }, + ) + : error, + ), + ); +} + export const openCodexThread = (input: { readonly client: CodexThreadOpenClient; readonly threadId: ThreadId; @@ -457,7 +493,7 @@ export const openCodexThread = (input: { }); if (resumeThreadId === undefined) { - return input.client.request("thread/start", startParams); + return startCodexThread(input.client, startParams); } return input.client @@ -473,7 +509,7 @@ export const openCodexThread = (input: { resumeThreadId, recoverable: true, cause: error, - }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), + }).pipe(Effect.andThen(startCodexThread(input.client, startParams))), ), ); }; @@ -707,6 +743,7 @@ export const makeCodexSessionRuntime = ( const pendingApprovalsRef = yield* Ref.make(new Map()); const approvalCorrelationsRef = yield* Ref.make(new Map()); const pendingUserInputsRef = yield* Ref.make(new Map()); + const dynamicToolWaitRegistry = yield* makeT3CodexDynamicToolWaitRegistry(); const collabReceiverTurnsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); @@ -1113,6 +1150,8 @@ export const makeCodexSessionRuntime = ( }), ); + yield* client.handleServerRequest("item/tool/call", dynamicToolWaitRegistry.handle); + yield* client.handleUnknownServerRequest((method) => Effect.fail(CodexErrors.CodexAppServerRequestError.methodNotFound(method)), ); @@ -1245,6 +1284,7 @@ export const makeCodexSessionRuntime = ( } yield* settlePendingApprovals("cancel"); yield* settlePendingUserInputs({}); + yield* dynamicToolWaitRegistry.cancelAll; yield* updateSession(sessionRef, { status: "closed", activeTurnId: undefined, @@ -1320,6 +1360,7 @@ export const makeCodexSessionRuntime = ( if (!effectiveTurnId) { return; } + yield* dynamicToolWaitRegistry.cancelTurn(effectiveTurnId); yield* client.request("turn/interrupt", { threadId: providerThreadId, turnId: effectiveTurnId, diff --git a/docs/project/deferred-thread-resume.md b/docs/project/deferred-thread-resume.md new file mode 100644 index 00000000000..f734f94a443 --- /dev/null +++ b/docs/project/deferred-thread-resume.md @@ -0,0 +1,174 @@ +# Deferred thread resume + +## Summary + +Long-running external work currently encourages an agent to poll: sleep, spend another inference +turn checking status, and repeat. T3 should provide host-owned waiting primitives so elapsed time +does not consume model inference. + +This project is split into two deliberately different capabilities: + +1. **Live wait** — a tool call remains open while the current provider turn and T3 server process + remain alive. The host completes the tool call after a delay or cancellation. This is the first + implementation milestone and the scope of the initial PR. +2. **Durable resume** — the current turn ends, T3 persists a waitpoint, and a scheduler starts a new + turn after a timer or external event. This is required for restarts, webhook triggers, and + long-lived watches such as pull-request CI. It is a follow-up because its lifecycle and delivery + semantics are materially different from a blocking tool call. + +## Problem + +Agents commonly monitor commands, CI, deployments, and review bots by polling every few seconds. +Most polls provide no new information, but each one wakes the model and consumes inference. T3 +already owns provider sessions and their UI lifecycle, so it is the right layer to wait without +asking the model to reason again. + +## Goals + +- Let a Codex agent wait from 1 second to 1 hour without periodic model inference. +- Show the wait through the existing dynamic-tool lifecycle in the thread timeline. +- Cancel a live wait promptly when its turn is interrupted or its provider session closes. +- Keep the first change isolated to the Codex provider runtime and compatible with app-server + versions that ignore unknown experimental `thread/start` fields. +- Define the durable timer/trigger architecture before extending the feature to external events. + +## Non-goals for the initial PR + +- Surviving a T3 process restart. +- Waking a completed thread from a webhook or WebSocket event. +- Polling GitHub on behalf of an agent. +- Provider-neutral tool injection. Claude, OpenCode, and ACP need equivalent provider hooks before + they can expose the same capability. +- A new bespoke waiting UI. Codex already emits dynamic-tool started/completed events and T3 already + renders them. + +## User and agent experience + +T3 registers a namespaced `t3.wait` dynamic tool when it starts a new Codex thread. The tool accepts: + +- `durationMs`: integer duration from 1,000 through 3,600,000 milliseconds. +- `reason`: optional short explanation shown in the tool arguments. + +The tool description tells the agent to use it for intentional idle periods while external work is +running, and not for ordinary command execution or sub-second delays. While it is open, the thread +remains visibly active. On expiry it returns the elapsed duration and the agent may perform one +status check. Interrupting the turn cancels the wait before T3 sends the provider interrupt request. + +## Technical approach: live wait + +### Provider registration + +Codex app-server supports experimental `dynamicTools` on `thread/start` and sends calls to the host +as `item/tool/call` server requests. T3 already initializes app-server with experimental API support. +The checked-in generated schema exposes the dynamic-tool specification and call types but currently +omits the experimental `dynamicTools` property from `V2ThreadStartParams`, so thread creation uses +the raw protocol request and decodes the response with the existing generated response schema. + +The field is only sent on fresh `thread/start`. A resumed provider thread keeps the tool catalog +with which it was created; old threads therefore gain the tool after a recoverable resume fallback +or when the user starts a new thread. + +### Execution and cancellation + +The handler validates the namespace, tool name, and bounded duration. It races an Effect sleep +against a per-turn cancellation deferred. The deferred is completed before `turn/interrupt` and all +outstanding wait deferreds are completed during session shutdown. + +The response uses Codex's normal `DynamicToolCallResponse`: successful expiry returns +`success: true`; invalid input, unknown tools, and cancellation return `success: false` with a short +text result. No timer loop and no model request occurs while the Effect sleep is pending. + +### Compatibility + +`dynamicTools` is an experimental app-server field. Older app-server versions deserialize unknown +fields permissively, so thread creation continues and the tool is simply unavailable. Current +versions advertise the tool to the model and issue `item/tool/call` requests. T3's existing Codex +adapter maps `dynamicToolCall` lifecycle items to canonical `dynamic_tool_call` activities, and the +web timeline already renders those activities. + +## Follow-up architecture: durable resume + +A durable implementation should not keep a provider request or inference turn open. It needs a +persisted waitpoint owned by orchestration: + +```text +agent registers waitpoint -> current turn settles -> scheduler sleeps outside inference + ^ | + | v + timer / webhook / provider event -> claim waitpoint -> start one continuation turn +``` + +Suggested waitpoint fields: + +- id, T3 thread id, provider instance id, and original turn id +- kind: `timer`, `webhook`, or provider-specific event +- condition payload and optional deadline +- continuation prompt/template +- state: `pending`, `claimed`, `delivered`, `cancelled`, or `expired` +- idempotency key, attempt count, timestamps, and last error + +Delivery should be at-least-once with an atomic claim and idempotent continuation key. On startup, +the scheduler reloads pending waitpoints and re-arms timers. External triggers should enter through +authenticated, scoped adapters rather than exposing a generic unauthenticated callback. A GitHub PR +watcher can then translate check-suite, review, and pull-request webhooks into normalized waitpoint +signals. The sidebar/thread lifecycle should add an explicit `waiting` state only for this durable +mode; a live wait remains an active tool call. + +## Alternatives considered + +- **Agent-side polling:** no server work, but repeatedly consumes inference and is the behavior this + feature is intended to replace. +- **MCP wait server:** portable across clients, but T3 would lose direct lifecycle ownership and + cancellation unless it also hosted and correlated the MCP server. Codex dynamic tools give T3 a + smaller first integration. +- **Implement durable wake immediately:** covers timers and webhooks, but requires persistence, + scheduler recovery, continuation policy, UI state, authorization, and idempotency in one change. + Keeping live and durable semantics separate makes the first PR reviewable and testable. +- **Keep a WebSocket open for every trigger:** useful as an adapter transport, but not sufficient as + the source of truth because sockets do not survive process or network failure. + +## Test plan + +Focused automated coverage: + +- Thread start sends the namespaced dynamic-tool schema through the raw request path and decodes the + response. +- Recoverable resume fallback starts a fresh thread with the tool schema. +- Valid waits remain pending until the Effect test clock reaches the requested duration. +- Duration bounds, malformed arguments, wrong namespaces, and unknown tool names fail without sleep. +- Cancellation wins the race and returns a failed/cancelled tool result. +- Existing provider adapter tests continue to verify canonical dynamic-tool activity mapping. + +Targeted validation: + +- Format and lint changed files. +- Run the focused Codex runtime/dynamic-tool tests. +- Typecheck the server package. +- Manually start a Codex thread against a compatible app-server, request a short wait, confirm one + tool activity appears and completes, then repeat while interrupting the turn. + +## Rollout and observability + +The initial feature has no migration and no feature flag. App-server compatibility degrades by +omitting the tool rather than breaking the session. Existing provider event ingestion records the +dynamic-tool lifecycle. A follow-up may add wait duration/cancellation metrics once the contract is +used beyond Codex. + +## Risks + +- Experimental app-server protocol changes: isolate the raw field construction and response decode + so regeneration can replace it cleanly. +- Excessive waits: enforce a one-hour upper bound. +- Interrupted waits continuing in the background: correlate cancellation by provider turn and settle + it before sending the interrupt request. +- Confusing live and durable behavior: name and document the initial tool as a live wait and do not + claim restart or webhook guarantees. + +## Definition of done for the initial PR + +- A new Codex thread receives the `t3.wait` tool. +- A valid call sleeps in T3 and completes without model polling. +- Interrupt and session close cancel outstanding waits. +- Focused tests and server typecheck pass. +- The provider documentation explains the capability and its live-session limitation. +- The PR explicitly links this design and leaves durable waitpoints as follow-up scope. diff --git a/docs/providers/codex.md b/docs/providers/codex.md index cc9e84e484a..e9e68d2c228 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -27,6 +27,18 @@ Log in with Codex normally: codex login ``` +## Waiting Without Polling + +New Codex threads receive a T3-hosted `t3.wait` tool. An agent can use it to wait from one second to +one hour while an external command, CI job, deployment, or other task is running. The timer runs in +T3 Code and does not repeatedly wake the model, so the agent can perform one status check after the +wait instead of spending inference on frequent polling. + +This is a live-session wait. It is cancelled if the current turn is interrupted, the provider +session closes, or T3 Code stops. It does not wake a completed thread after an app restart and does +not subscribe to external webhooks. Durable timers and event-triggered continuation are described in +the [deferred thread resume design](../project/deferred-thread-resume.md). + ## I Want Work And Personal Codex Accounts Use one real Codex home and one shadow home.