diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index b483a3f99f8e..197eb82e1517 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -115,6 +115,7 @@ const startupDependencies = Layer.mergeAll( startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), compactThread: () => Effect.die("unused"), + clearGoal: () => Effect.die("unused"), interruptTurn: () => Effect.die("unused"), respondToRequest: () => Effect.die("unused"), respondToUserInput: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1d0b3da1bdbc..81eff3e9a716 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -120,6 +120,7 @@ function createProviderServiceHarness( startSession: () => unsupported(), sendTurn: () => unsupported(), compactThread: () => unsupported(), + clearGoal: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1acb3c360b15..be33205688f5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1413,7 +1413,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.activity-appended": { - if (event.payload.activity.kind === "context-compaction") { + if ( + event.payload.activity.kind === "context-compaction" || + event.payload.activity.kind === "provider.goal.cleared" + ) { const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId( event.payload, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 87f9bd03d46c..06486083021c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -182,6 +182,11 @@ describe("ProviderCommandReactor", () => { readonly beforeTurnStartDispatch?: () => Effect.Effect; readonly afterTurnStartDispatch?: () => Effect.Effect; readonly compactThreadEffect?: () => Effect.Effect; + readonly clearGoalEffect?: () => Effect.Effect< + { readonly cleared: boolean }, + ProviderServiceError + >; + readonly beforeInstanceInfo?: () => Effect.Effect; readonly interruptTurnEffect?: () => Effect.Effect; readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( @@ -274,6 +279,9 @@ describe("ProviderCommandReactor", () => { }), ); const compactThread = vi.fn((_: ThreadId) => input?.compactThreadEffect?.() ?? Effect.void); + const clearGoal = vi.fn((_: ThreadId) => { + return input?.clearGoalEffect?.() ?? Effect.succeed({ cleared: true as const }); + }); const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); @@ -361,6 +369,7 @@ describe("ProviderCommandReactor", () => { startSession: startSession as ProviderServiceShape["startSession"], sendTurn: sendTurn as ProviderServiceShape["sendTurn"], compactThread, + clearGoal, interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], @@ -382,19 +391,21 @@ describe("ProviderCommandReactor", () => { ? "antigravity" : raw, ); - return Effect.succeed({ - instanceId, - driverKind, - displayName: undefined, - enabled: true, - continuationIdentity: { + return (input?.beforeInstanceInfo?.() ?? Effect.void).pipe( + Effect.as({ + instanceId, driverKind, - continuationKey: - driverKind === ProviderDriverKind.make("codex") - ? "codex:home:/shared-codex" - : `${driverKind}:instance:${instanceId}`, - }, - }); + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: + driverKind === ProviderDriverKind.make("codex") + ? "codex:home:/shared-codex" + : `${driverKind}:instance:${instanceId}`, + }, + }), + ); }, rollbackConversation: () => unsupported(), uploadFeedback: () => unsupported(), @@ -617,6 +628,7 @@ describe("ProviderCommandReactor", () => { startSession, sendTurn, compactThread, + clearGoal, interruptTurn, respondToRequest, respondToUserInput, @@ -1125,6 +1137,515 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("clears a Codex goal instead of sending /goal clear as a turn", () => + Effect.gen(function* () { + const cleared = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + clearGoalEffect: () => + Deferred.succeed(cleared, undefined).pipe(Effect.as({ cleared: true })), + }), + ); + const threadId = ThreadId.make("thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear"), + threadId, + message: { + messageId: MessageId.make("message-goal-clear"), + role: "user", + text: "/goal clear", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(cleared); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return ( + thread?.activities.some((activity) => activity.kind === "provider.goal.cleared") ?? + false + ); + }), + ); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.messages.map((message) => message.text)).toEqual(["/goal clear"]); + expect(thread?.activities).toContainEqual( + expect.objectContaining({ + kind: "provider.goal.cleared", + tone: "info", + summary: "Goal cleared", + turnId: null, + }), + ); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + expect(harness.clearGoal).toHaveBeenCalledWith(threadId); + expect(harness.startSession).toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("reports when Codex has no goal to clear", () => + Effect.gen(function* () { + const cleared = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + clearGoalEffect: () => + Deferred.succeed(cleared, undefined).pipe(Effect.as({ cleared: false })), + }), + ); + const threadId = ThreadId.make("thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-empty"), + threadId, + message: { + messageId: MessageId.make("message-goal-clear-empty"), + role: "user", + text: " /Goal Clear ", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(cleared); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return ( + thread?.activities.some((activity) => activity.summary === "No goal to clear") ?? false + ); + }), + ); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.activities).toContainEqual( + expect.objectContaining({ kind: "provider.goal.cleared", summary: "No goal to clear" }), + ); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("does not clear a Codex goal while a turn is running", () => + Effect.gen(function* () { + const lookedUp = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + beforeInstanceInfo: () => Deferred.succeed(lookedUp, undefined), + }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-goal-clear-running-session"), + threadId, + session: { + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "codex", + status: "running", + runtimeMode: "approval-required", + activeTurnId: TurnId.make("turn-running"), + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-running"), + threadId, + message: { + messageId: MessageId.make("message-goal-clear-running"), + role: "user", + text: "/goal clear", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + yield* Deferred.await(lookedUp); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.activities).toContainEqual( + expect.objectContaining({ + kind: "provider.turn.start.failed", + summary: "Could not clear the goal", + payload: expect.objectContaining({ + detail: "Goal clear is unavailable while a provider turn is running.", + }), + }), + ); + expect(harness.clearGoal).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect( + "sends /goal clear as a normal turn after a stopped Codex session switches provider", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("claude"), + model: "claude-sonnet", + }, + startSessionEffect: (session) => + Deferred.succeed(started, undefined).pipe(Effect.as(session)), + }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-goal-clear-stopped-codex"), + threadId, + session: { + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "codex", + status: "stopped", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-switched"), + threadId, + message: { + messageId: MessageId.make("message-goal-clear-switched"), + role: "user", + text: "/goal clear", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + yield* Deferred.await(started); + yield* Effect.promise(() => harness.drain()); + + expect(harness.clearGoal).not.toHaveBeenCalled(); + expect(harness.sendTurn).toHaveBeenCalledWith( + expect.objectContaining({ input: "/goal clear" }), + ); + }), + ); + + effectIt.effect("clears on the active Codex session when the draft model is not Codex", () => + Effect.gen(function* () { + const cleared = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + clearGoalEffect: () => + Deferred.succeed(cleared, undefined).pipe(Effect.as({ cleared: true })), + }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-goal-clear-active-codex"), + threadId, + session: { + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "codex", + status: "ready", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-active"), + threadId, + message: { + messageId: MessageId.make("message-goal-clear-active"), + role: "user", + text: "/goal clear", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("claude"), + model: "claude-sonnet", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + yield* Deferred.await(cleared); + yield* Effect.promise(() => harness.drain()); + + expect(harness.clearGoal).toHaveBeenCalledWith(threadId); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("reports a goal-clear lookup failure instead of sending the command", () => + Effect.gen(function* () { + const lookedUp = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + beforeInstanceInfo: () => + Deferred.succeed(lookedUp, undefined).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread/goal/clear", + detail: "wire payload sk-secret-lookup", + }), + ), + ), + ), + }), + ); + const threadId = ThreadId.make("thread-1"); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-lookup"), + threadId, + message: { + messageId: MessageId.make("message-goal-clear-lookup"), + role: "user", + text: "/goal clear", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(lookedUp); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + const failure = thread?.activities.find( + (activity) => activity.kind === "provider.turn.start.failed", + ); + expect(failure).toEqual( + expect.objectContaining({ + summary: "Could not clear the goal", + payload: expect.objectContaining({ + detail: "The provider could not clear the goal.", + }), + }), + ); + expect(failure?.summary).not.toContain("sk-secret-lookup"); + expect( + failure?.payload && + typeof failure.payload === "object" && + "detail" in failure.payload && + failure.payload.detail, + ).not.toContain("sk-secret-lookup"); + expect(harness.clearGoal).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("persists a bounded detail when goal clear fails", () => + Effect.gen(function* () { + const failed = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + clearGoalEffect: () => + Deferred.succeed(failed, undefined).pipe( + Effect.andThen(Effect.die(new Error("wire payload sk-secret-clear"))), + ), + }), + ); + const threadId = ThreadId.make("thread-1"); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-defect"), + threadId, + message: { + messageId: MessageId.make("message-goal-clear-defect"), + role: "user", + text: "/goal clear", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(failed); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return ( + thread?.activities.some( + (activity) => activity.summary === "Could not clear the goal", + ) ?? false + ); + }), + ); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + const failure = thread?.activities.find( + (activity) => activity.kind === "provider.turn.start.failed", + ); + expect(failure).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + detail: "The provider could not clear the goal.", + }), + }), + ); + expect(failure?.summary).not.toContain("sk-secret-clear"); + expect( + failure?.payload && + typeof failure.payload === "object" && + "detail" in failure.payload && + failure.payload.detail, + ).not.toContain("sk-secret-clear"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("keeps handling other threads while Codex goal clear is in flight", () => + Effect.gen(function* () { + const clearStarted = yield* Deferred.make(); + const releaseClear = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + clearGoalEffect: () => + Deferred.succeed(clearStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseClear)), + Effect.as({ cleared: true }), + ), + startSessionEffect: (session) => + session.threadId === ThreadId.make("thread-2") + ? Deferred.succeed(secondStarted, undefined).pipe(Effect.as(session)) + : Effect.succeed(session), + }), + ); + const createdAt = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-goal-clear-other"), + threadId: ThreadId.make("thread-2"), + projectId: asProjectId("project-1"), + title: "Thread 2", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + yield* Effect.promise(() => harness.drain()); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-blocking"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-goal-clear-blocking"), + role: "user", + text: "/goal clear", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + yield* Deferred.await(clearStarted); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-other-thread"), + threadId: ThreadId.make("thread-2"), + message: { + messageId: MessageId.make("message-goal-clear-other-thread"), + role: "user", + text: "continue elsewhere", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + yield* Deferred.await(secondStarted).pipe(Effect.timeout("5 seconds")); + yield* Deferred.succeed(releaseClear, undefined); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn).toHaveBeenCalledWith( + expect.objectContaining({ input: "continue elsewhere" }), + ); + }), + ); + + effectIt.effect.each([ + { label: "another goal command", text: "/goal status", instanceId: "codex" }, + { label: "a non-Codex thread", text: "/goal clear", instanceId: "claude" }, + ])("sends $label as a normal turn", ({ text, instanceId }) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make(instanceId), + model: "test-model", + }, + startSessionEffect: (session) => + Deferred.succeed(started, undefined).pipe(Effect.as(session)), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-goal-clear-passthrough"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-goal-clear-passthrough"), + role: "user", + text, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(started); + yield* Effect.promise(() => harness.drain()); + + expect(harness.clearGoal).not.toHaveBeenCalled(); + expect(harness.sendTurn).toHaveBeenCalledWith(expect.objectContaining({ input: text })); + }), + ); + effectIt.effect.each(["resume", "stop before resume", "stop after send"])( "queues messages until compaction restores the session (%s)", (scenario) => diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 77022a8518d7..c83ea0412c4a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -97,6 +97,14 @@ const isCompactCommandMessage = (message: ThreadTitleMessage): boolean => message.role === "user" && (message.attachments?.length ?? 0) === 0 && message.text.trim().toLowerCase() === "/compact"; + +const GOAL_CLEAR_TIMEOUT = "30 seconds"; +const GOAL_CLEAR_FAILURE_DETAIL = "The provider could not clear the goal."; + +const isGoalClearCommandMessage = (message: ThreadTitleMessage): boolean => + message.role === "user" && + (message.attachments?.length ?? 0) === 0 && + message.text.trim().toLowerCase().replace(/\s+/g, " ") === "/goal clear"; function mapProviderSessionStatusToOrchestrationStatus( status: "connecting" | "ready" | "running" | "error" | "closed", ): OrchestrationSession["status"] { @@ -1345,7 +1353,38 @@ const make = Effect.gen(function* () { yield* ensureThreadWorktree(thread); const isCompactCommand = isCompactCommandMessage(message); - if (!hasOtherUserMessages && !isCompactCommand) { + // A stopped session still carries the previous provider. Reusing it would + // clear a Codex goal after the user switched models, instead of sending + // the text to the provider the next turn actually starts. + const goalClearInstanceId = + thread.session !== null && + thread.session.status !== "stopped" && + thread.session.providerInstanceId !== undefined + ? thread.session.providerInstanceId + : (event.payload.modelSelection?.instanceId ?? thread.modelSelection.instanceId); + const goalClearDriver = isGoalClearCommandMessage(message) + ? yield* providerService.getInstanceInfo(goalClearInstanceId).pipe( + Effect.map((info) => info.driverKind), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to resolve goal clear provider", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }).pipe( + Effect.andThen( + appendTurnStartFailure("Could not clear the goal", GOAL_CLEAR_FAILURE_DETAIL), + ), + Effect.as(null), + ), + ), + ) + : null; + if (isGoalClearCommandMessage(message) && goalClearDriver === null) { + return; + } + const handleGoalClear = goalClearDriver === ProviderDriverKind.make("codex"); + if (!hasOtherUserMessages && !isCompactCommand && !handleGoalClear) { const project = yield* resolveProject(thread.projectId); const generationCwd = resolveThreadWorkspaceCwd({ @@ -1490,6 +1529,126 @@ const make = Effect.gen(function* () { turnsAfterCompaction.set(event.payload.threadId, queued); return; } + if (handleGoalClear) { + const latestThread = yield* resolveThreadShell(event.payload.threadId); + if ( + latestThread?.session?.status === "starting" || + latestThread?.session?.status === "running" + ) { + yield* appendTurnStartFailure( + "Could not clear the goal", + "Goal clear is unavailable while a provider turn is running.", + ); + return; + } + + const goalClearModelSelection = + event.payload.modelSelection?.instanceId === goalClearInstanceId + ? event.payload.modelSelection + : thread.modelSelection.instanceId === goalClearInstanceId + ? thread.modelSelection + : { ...thread.modelSelection, instanceId: goalClearInstanceId }; + // Forked so a wedged thread/goal/clear cannot stall the shared worker. + // The RPC itself is bounded; the transport otherwise waits forever. + yield* Effect.gen(function* () { + const cleared = yield* Effect.gen(function* () { + yield* ensureSessionForThread(event.payload.threadId, event.payload.createdAt, { + modelSelection: goalClearModelSelection, + }); + const timed = yield* providerService + .clearGoal(event.payload.threadId) + .pipe(Effect.timeoutOption(GOAL_CLEAR_TIMEOUT)); + if (Option.isNone(timed)) { + return { _tag: "timeout" as const }; + } + return { _tag: "cleared" as const, result: timed.value }; + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("provider command reactor failed to clear goal", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }).pipe( + Effect.andThen( + appendTurnStartFailure("Could not clear the goal", GOAL_CLEAR_FAILURE_DETAIL), + ), + Effect.as({ _tag: "failed" as const }), + ); + }), + ); + if (cleared._tag === "failed") { + return; + } + if (cleared._tag === "timeout") { + yield* Effect.logWarning("provider command reactor timed out clearing goal", { + threadId: event.payload.threadId, + }); + yield* appendTurnStartFailure("Could not clear the goal", GOAL_CLEAR_FAILURE_DETAIL); + return; + } + const result = cleared.result; + const createdAt = event.payload.createdAt; + yield* Effect.all({ + commandId: serverCommandId("provider-goal-clear"), + eventId: serverEventId(), + }).pipe( + Effect.flatMap(({ commandId, eventId }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: event.payload.threadId, + activity: { + id: eventId, + tone: "info", + kind: "provider.goal.cleared", + summary: result.cleared ? "Goal cleared" : "No goal to clear", + payload: { + cleared: result.cleared, + requestId: event.payload.messageId, + }, + turnId: null, + createdAt, + }, + createdAt, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to record goal clear", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }), + ), + ); + const settledThread = yield* resolveThreadShell(event.payload.threadId); + const session = settledThread?.session; + if (session && session.status !== "running" && session.status !== "stopped") { + yield* setThreadSession({ + threadId: event.payload.threadId, + session: { + ...session, + status: "ready", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider command reactor failed to settle session after goal clear", + { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }, + ), + ), + ); + } + }).pipe(Effect.forkScoped); + return; + } const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: projectComposerContextForProvider({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 4c6e3703ba82..ef929ed65d05 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -126,6 +126,7 @@ function createProviderServiceHarness() { startSession: () => unsupported(), sendTurn: () => unsupported(), compactThread: () => unsupported(), + clearGoal: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9aff859521fc..795726b16712 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -86,6 +86,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { public readonly compactThread = Effect.void; + public readonly clearGoalImpl = vi.fn(() => Promise.resolve({ cleared: true })); + + clearGoal = Effect.promise(() => this.clearGoalImpl()); + public readonly interruptTurnImpl = vi.fn((_turnId?: TurnId): Promise => Promise.resolve(undefined), ); @@ -379,6 +383,26 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("clears the persisted goal through the Codex app-server", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-goal-clear"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const result = yield* adapter.clearGoal!(threadId); + + NodeAssert.deepStrictEqual(result, { cleared: true }); + NodeAssert.equal(runtime.clearGoalImpl.mock.calls.length, 1); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("uploads feedback for the active Codex thread", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 64c7012c4a92..dd235d719f64 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2662,6 +2662,13 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }); + const clearGoal = Effect.fn("clearGoal")(function* (threadId: ThreadId) { + const session = yield* requireSession(threadId); + return yield* session.runtime.clearGoal.pipe( + Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/goal/clear", cause)), + ); + }); + const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.readThread), @@ -2808,6 +2815,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( startSession, sendTurn, compaction: { type: "native", start: compactThread }, + clearGoal, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 72ca3fd74f0d..3bcb0278f931 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -702,6 +702,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu description: "Send this thread and Codex logs to OpenAI", input: { hint: "Describe the issue (optional)" }, }, + { + name: "goal clear", + description: "Remove the persisted goal", + }, ], probe: { installed: true, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index f30baf011e8a..412fd6d6f693 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -218,6 +218,7 @@ export interface CodexSessionRuntimeShape { input: CodexSessionRuntimeSendTurnInput, ) => Effect.Effect; readonly compactThread: Effect.Effect; + readonly clearGoal: Effect.Effect<{ readonly cleared: boolean }, CodexSessionRuntimeError>; readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; readonly readThread: Effect.Effect; readonly rollbackThread: ( @@ -2547,6 +2548,10 @@ export const makeCodexSessionRuntime = ( const providerThreadId = yield* readProviderThreadId; yield* client.request("thread/compact/start", { threadId: providerThreadId }); }), + clearGoal: Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + return yield* client.request("thread/goal/clear", { threadId: providerThreadId }); + }), sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0970ea88748b..3b65ffa7fc69 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -433,6 +433,10 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test description: "Send this thread and Codex logs to OpenAI", input: { hint: "Describe the issue (optional)" }, }, + { + name: "goal clear", + description: "Remove the persisted goal", + }, ]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7acf3fcb7b01..7be136764dfe 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -203,6 +203,11 @@ function makeFakeCodexAdapter( }), ), ); + + const clearGoal = vi.fn( + (_threadId: ThreadId): Effect.Effect<{ readonly cleared: boolean }, ProviderAdapterError> => + Effect.succeed({ cleared: true }), + ); const respondToRequest = vi.fn( ( _threadId: ThreadId, @@ -294,7 +299,7 @@ function makeFakeCodexAdapter( hasSession, readThread, rollbackThread, - ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}), + ...(provider === CODEX_DRIVER ? { uploadFeedback, clearGoal } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -332,6 +337,7 @@ function makeFakeCodexAdapter( readThread, rollbackThread, uploadFeedback, + clearGoal, stopAll, }; } @@ -2143,6 +2149,68 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes goal clear to the Codex adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-goal-clear-route"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + routing.codex.clearGoal.mockClear(); + + const result = yield* provider.clearGoal(threadId); + + assert.deepStrictEqual(result, { cleared: true }); + assert.deepStrictEqual(routing.codex.clearGoal.mock.calls, [[threadId]]); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("recovers a Codex session before clearing its goal", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-goal-clear-recover"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* routing.codex.adapter.stopSession(threadId); + routing.codex.startSession.mockClear(); + routing.codex.clearGoal.mockClear(); + + const result = yield* provider.clearGoal(threadId); + + assert.deepStrictEqual(result, { cleared: true }); + assert.equal(routing.codex.startSession.mock.calls.length, 1); + assert.deepStrictEqual(routing.codex.clearGoal.mock.calls, [[threadId]]); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("rejects goal clear for providers without a goal channel", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-goal-clear-claude"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const error = yield* provider.clearGoal(threadId).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support clearing a goal"); + yield* provider.stopSession({ threadId }); + }), + ); + it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a90a77ab86f4..4b52d37265ab 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1961,6 +1961,29 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); + const clearGoal: ProviderServiceMethod<"clearGoal"> = Effect.fn("clearGoal")( + function* (threadId) { + const routed = yield* resolveRoutableSession({ + threadId, + operation: "ProviderService.clearGoal", + allowRecovery: true, + }); + const clearGoalForAdapter = routed.adapter.clearGoal; + if (clearGoalForAdapter === undefined) { + return yield* toValidationError( + "ProviderService.clearGoal", + `Provider '${routed.adapter.provider}' does not support clearing a goal.`, + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "clear-goal", + "provider.kind": routed.adapter.provider, + "provider.thread_id": threadId, + }); + return yield* clearGoalForAdapter(routed.threadId); + }, + ); + const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -2453,6 +2476,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( startSession, sendTurn, compactThread, + clearGoal, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 7b1fec90f867..530075902968 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -193,6 +193,7 @@ describe("ProviderSessionReaper", () => { startSession: () => unsupported(), sendTurn: () => unsupported(), compactThread: () => unsupported(), + clearGoal: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c9b62fd79525..09bce0e371d0 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -88,6 +88,12 @@ export interface ProviderAdapterShape { /** Omitted when this adapter does not support manual context compaction. */ readonly compaction?: ProviderCompaction; + /** + * Clear a persisted provider goal without starting a model turn. + * Omitted when this adapter has no goal control channel. + */ + readonly clearGoal?: (threadId: ThreadId) => Effect.Effect<{ readonly cleared: boolean }, TError>; + /** * Interrupt an active turn. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index c189e2916ff1..8239280c89cf 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -60,6 +60,14 @@ export interface ProviderServiceShape { requestId?: MessageId, ) => Effect.Effect; + /** + * Clear a persisted provider goal. Fails when the bound adapter has no goal channel. + * Recovers a stopped session when the binding still has resume state. + */ + readonly clearGoal: ( + threadId: ThreadId, + ) => Effect.Effect<{ readonly cleared: boolean }, ProviderServiceError>; + /** * Interrupt a running provider turn. */ diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 050650b11515..64cece1548a9 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -59,6 +59,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), compactThread: () => Effect.die("unused"), + clearGoal: () => Effect.die("unused"), interruptTurn: () => Effect.die("unused"), respondToRequest: () => Effect.die("unused"), respondToUserInput: () => Effect.die("unused"), diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 83f143a546fd..11e2c9c1a304 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -208,6 +208,20 @@ describe("detectComposerTrigger", () => { expect(trigger).toBeNull(); }); + it("detects the multi-word /goal clear command", () => { + const text = "/goal clear"; + expect(detectComposerTrigger(text, text.length)).toEqual({ + kind: "slash-command", + query: "goal clear", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("ends a multi-word slash command when a trailing space dismisses the menu", () => { + expect(detectComposerTrigger("/goal clear ", "/goal clear ".length)).toBeNull(); + }); + it("detects non-model slash commands while typing", () => { const text = "/pl"; const trigger = detectComposerTrigger(text, text.length); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index ff638a6d2e46..0c7c58b65d4b 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -231,6 +231,21 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } + + // Multi-word provider commands (`/goal clear`). A trailing space still ends + // the trigger so choosing the command dismisses the menu. `/model` arguments + // stay closed; that line belongs to the model picker. + if (!/^\/model(?:\s|$)/i.test(linePrefix)) { + const spacedMatch = /^\/(\S+(?:\s+\S+)+)$/.exec(linePrefix); + if (spacedMatch) { + return { + kind: "slash-command", + query: spacedMatch[1] ?? "", + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + } } const tokenStart = tokenStartForCursor(text, cursor); diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index bdc87902a63b..53b10549caff 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -99,6 +99,13 @@ when it resets, when Codex reports them. Send the message again after the reset. message also says whether your workspace owner needs to add credits or raise the spend limit to continue sooner. +## Clear a goal + +In an existing Codex thread, send `/goal clear` to remove the persisted goal. +The thread and project files stay. T3 Code confirms when the goal is cleared, or +when there was no goal to remove. Wait for the current turn to finish first; +goal clear is unavailable while Codex is working. + ## Send feedback to OpenAI In an existing Codex thread, send `/feedback` with an optional description, for diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 06b7038dad2b..7d1263581768 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from "vite-plus/test"; import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts"; describe("detectComposerTrigger", () => { + it("detects a multi-word slash command and leaves /model arguments as model search", () => { + expect(detectComposerTrigger("/goal clear", "/goal clear".length)).toEqual({ + kind: "slash-command", + query: "goal clear", + rangeStart: 0, + rangeEnd: "/goal clear".length, + }); + expect(detectComposerTrigger("/goal clear ", "/goal clear ".length)).toBeNull(); + expect(detectComposerTrigger("/model spark", "/model spark".length)).toEqual({ + kind: "slash-model", + query: "spark", + rangeStart: 0, + rangeEnd: "/model spark".length, + }); + }); + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( "detects %s skill prefixes and their source range", (prefix) => { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 6153978edb92..91b94cf522fa 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -90,6 +90,18 @@ export function detectComposerTrigger( rangeEnd: cursor, }; } + + // Multi-word provider commands (`/goal clear`). A trailing space still ends + // the trigger so choosing the command dismisses the menu. + const spacedMatch = /^\/(\S+(?:\s+\S+)+)$/.exec(linePrefix); + if (spacedMatch) { + return { + kind: "slash-command", + query: spacedMatch[1] ?? "", + rangeStart: lineStart, + rangeEnd: cursor, + }; + } } const wsCheck = isWhitespaceChar ?? isWhitespace;