diff --git a/apps/mobile/src/features/threads/FreshSessionNotice.tsx b/apps/mobile/src/features/threads/FreshSessionNotice.tsx index 43ad7e6f32b7..3740090237e2 100644 --- a/apps/mobile/src/features/threads/FreshSessionNotice.tsx +++ b/apps/mobile/src/features/threads/FreshSessionNotice.tsx @@ -3,14 +3,20 @@ import { View } from "react-native"; import { Notice } from "../../components/ui/Notice"; import { setFreshSessionPending, useComposerDraftSettings } from "../../state/use-composer-drafts"; -/** Shown while `/clear` is armed; dismissing it keeps the current session. */ +/** Shown while `/clear` or `/recap` is armed; dismissing it keeps the current session. */ export function FreshSessionNotice(props: { readonly threadKey: string }) { - if (useComposerDraftSettings(props.threadKey).freshContext !== true) return null; + const settings = useComposerDraftSettings(props.threadKey); + if (settings.freshContext !== true) return null; + const withRecap = settings.contextHandoffOnFresh === true; return ( setFreshSessionPending(props.threadKey, false)} dismissLabel="Keep the current session" diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e36900736e8e..d56d1693d017 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -216,7 +216,7 @@ export interface ThreadComposerProps { readonly reportedModelSelection?: ModelSelection | null; readonly hasCompactableConversation: boolean; /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ - readonly onFreshSession?: () => void; + readonly onFreshSession?: (options?: { readonly withRecap?: boolean }) => void; readonly offersSideChat?: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 45d2aad72e42..44669797bd8c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1873,7 +1873,8 @@ function ThreadDetailBody( } {...(freshSessionAvailable ? { - onFreshSession: () => setFreshSessionPending(selectedThreadKey, true), + onFreshSession: (options?: { readonly withRecap?: boolean }) => + setFreshSessionPending(selectedThreadKey, true, options), } : {})} offersSideChat={ diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b2e947c5ab0e..40d6bc6f3772 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3578,8 +3578,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { info.item.message.projectedItem?.item.type === "user_message" && info.item.message.projectedItem.item.freshContext ? ( ) : null} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 17f970a1ef22..c363318c9a90 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -105,6 +105,26 @@ describe("mobile slash commands", () => { expect(build(false)).toEqual(["pcmd:clear"]); expect(build(true, false)).toEqual([]); }); + + it("offers /recap for Start fresh with recap when fresh sessions are available", () => { + const items = buildComposerSlashCommandItems({ + query: "rec", + atMessageStart: true, + hasThread: true, + offersFreshSession: true, + selectedProviderStatus: { + driver: ProviderDriverKind.make("claudeAgent"), + slashCommands: [], + }, + }); + expect(items.map((item) => item.id)).toEqual(["cmd:recap"]); + expect(items[0]).toMatchObject({ + type: "slash-command", + command: "recap", + label: "/recap", + description: "Start fresh with recap", + }); + }); }); describe("mobile model mentions", () => { diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 4bc5c2b257ff..f616f365188d 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -150,7 +150,16 @@ export function buildComposerSlashCommandItems(input: { type: "slash-command", command: "clear", label: "/clear", - description: "Start a fresh session", + description: "Start a fresh session without earlier context", + }); + } + if (input.offersFreshSession && "recap".includes(query)) { + items.push({ + id: "cmd:recap", + type: "slash-command", + command: "recap", + label: "/recap", + description: "Start fresh with recap", }); } for (const command of input.selectedProviderStatus?.slashCommands ?? []) { @@ -287,8 +296,11 @@ export function useComposerCommandMenu({ readonly onUsageLimits?: () => void; readonly modelOptions?: ReadonlyArray; readonly modelProviders?: ReadonlyArray; - /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ - readonly onFreshSession?: () => void; + /** + * Arms a fresh session for the next message; absent where `/clear` / `/recap` are unavailable. + * Pass `{ withRecap: true }` for Start fresh with recap. + */ + readonly onFreshSession?: (options?: { readonly withRecap?: boolean }) => void; readonly offersSideChat?: boolean; }) { const [localSelection, setLocalSelection] = useState(() => ({ @@ -632,11 +644,15 @@ export function useComposerCommandMenu({ return; } - if (item.type === "slash-command" && item.command === "clear" && onFreshSession) { + if ( + item.type === "slash-command" && + (item.command === "clear" || item.command === "recap") && + onFreshSession + ) { const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); setSelection({ start: cleared.cursor, end: cleared.cursor }); onChangeDraftMessage(cleared.text); - onFreshSession(); + onFreshSession(item.command === "recap" ? { withRecap: true } : undefined); return; } if ( diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 64fe62d8a15f..072e0c712588 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -60,6 +60,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), compactBeforeSend: Schema.optional(Schema.Boolean), freshContext: Schema.optional(Schema.Boolean), + contextHandoffOnFresh: Schema.optional(Schema.Boolean), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), creation: Schema.optional(QueuedThreadCreationSchema), @@ -95,8 +96,10 @@ export interface QueuedThreadMessage { readonly dispatchMode?: ComposerDispatchMode; /** Sends "When you return"'s `/compact` ahead of the message, which queues behind it. */ readonly compactBeforeSend?: boolean; - /** Starts a new provider session (`/clear`). */ + /** Starts a new provider session (`/clear` or `/recap`). */ readonly freshContext?: boolean; + /** With `freshContext`, deliver a durable written recap (`/recap`). */ + readonly contextHandoffOnFresh?: boolean; readonly creation?: QueuedThreadCreation; readonly createdAt: string; } diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index c64215ed26cd..a019ab0d968f 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -464,6 +464,7 @@ export async function restoreRejectedQueuedMessage( ...(queuedMessage.runtimeMode ? { runtimeMode: queuedMessage.runtimeMode } : {}), ...(queuedMessage.interactionMode ? { interactionMode: queuedMessage.interactionMode } : {}), ...(queuedMessage.freshContext ? { freshContext: true } : {}), + ...(queuedMessage.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), ...(queuedMessage.creation ? { workspaceSelection: { @@ -953,6 +954,7 @@ export function createThreadOutboxDelivery(): { dispatchMode: queuedMessage.dispatchMode ?? "start", ...(queuedMessage.freshContext ? { freshContext: true } : {}), resendAfterSocketLoss: false, + ...(queuedMessage.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), }, }); if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index b233034c5cf8..d5712ff91d0a 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -404,8 +404,10 @@ export interface ComposerDraft { readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; - /** `/clear` armed this draft: its next message starts a fresh provider session. */ + /** `/clear` or `/recap` armed this draft: its next message starts a fresh provider session. */ readonly freshContext?: boolean; + /** With `freshContext`, `/recap` also delivers a durable written handoff. */ + readonly contextHandoffOnFresh?: boolean; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; readonly project?: ComposerDraftProject; } @@ -443,6 +445,7 @@ export type ComposerDraftSettingsUpdate = Pick< | "runtimeMode" | "interactionMode" | "freshContext" + | "contextHandoffOnFresh" | "workspaceSelection" | "project" >; @@ -477,6 +480,7 @@ const ComposerDraftSchema = Schema.Struct({ runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), freshContext: Schema.optional(Schema.Boolean), + contextHandoffOnFresh: Schema.optional(Schema.Boolean), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), project: Schema.optional(ComposerDraftProjectSchema), }); @@ -657,6 +661,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.freshContext !== true && + draft.contextHandoffOnFresh !== true && draft.workspaceSelection === undefined ); } @@ -1556,15 +1561,34 @@ export function releaseComposerDraftModelSelection( }); } -export function setFreshSessionPending(draftKey: string, pending: boolean): void { - if ((getComposerDraftSnapshot(draftKey).freshContext === true) === pending) return; - updateComposerDraftSettings(draftKey, { freshContext: pending ? true : undefined }); +export function setFreshSessionPending( + draftKey: string, + pending: boolean, + options?: { readonly withRecap?: boolean }, +): void { + const draft = getComposerDraftSnapshot(draftKey); + const withRecap = pending && options?.withRecap === true; + if ( + (draft.freshContext === true) === pending && + (draft.contextHandoffOnFresh === true) === withRecap + ) { + return; + } + updateComposerDraftSettings(draftKey, { + freshContext: pending ? true : undefined, + contextHandoffOnFresh: withRecap ? true : undefined, + }); } export function isFreshSessionPending(draftKey: string): boolean { return getComposerDraftSnapshot(draftKey).freshContext === true; } +export function isFreshSessionRecapPending(draftKey: string): boolean { + const draft = getComposerDraftSnapshot(draftKey); + return draft.freshContext === true && draft.contextHandoffOnFresh === true; +} + export function subscribeComposerDraftModelReconciliation(input: { readonly registry: AtomRegistry.AtomRegistry; readonly threadRefsAtom: Atom.Atom>; @@ -1814,6 +1838,7 @@ export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): bool a.runtimeMode === b.runtimeMode && a.interactionMode === b.interactionMode && a.freshContext === b.freshContext && + a.contextHandoffOnFresh === b.contextHandoffOnFresh && a.workspaceSelection === b.workspaceSelection ); } @@ -1844,6 +1869,7 @@ export function undoComposerDraftMergeState( | "runtimeMode" | "interactionMode" | "freshContext" + | "contextHandoffOnFresh" | "workspaceSelection", >( key: K, @@ -1865,6 +1891,7 @@ export function undoComposerDraftMergeState( runtimeMode: undoSetting("runtimeMode"), interactionMode: undoSetting("interactionMode"), freshContext: undoSetting("freshContext"), + contextHandoffOnFresh: undoSetting("contextHandoffOnFresh"), workspaceSelection: undoSetting("workspaceSelection"), }; return withComposerDraft(current, draftKey, draft); @@ -2097,7 +2124,7 @@ export const composerDraftUploadSourcesAtom = Atom.make((get): ComposerDraftUplo type ComposerDraftSettings = Pick< ComposerDraft, - "modelSelection" | "runtimeMode" | "interactionMode" | "freshContext" + "modelSelection" | "runtimeMode" | "interactionMode" | "freshContext" | "contextHandoffOnFresh" >; const EMPTY_DRAFT_SETTINGS: ComposerDraftSettings = {}; const composerDraftSettingsAtom = Atom.family((draftKey: string) => @@ -2108,6 +2135,7 @@ const composerDraftSettingsAtom = Atom.family((draftKey: string) => runtimeMode: draft.runtimeMode, interactionMode: draft.interactionMode, freshContext: draft.freshContext, + contextHandoffOnFresh: draft.contextHandoffOnFresh, }; }).pipe( Atom.withEquality( @@ -2115,7 +2143,8 @@ const composerDraftSettingsAtom = Atom.family((draftKey: string) => a.modelSelection === b.modelSelection && a.runtimeMode === b.runtimeMode && a.interactionMode === b.interactionMode && - a.freshContext === b.freshContext, + a.freshContext === b.freshContext && + a.contextHandoffOnFresh === b.contextHandoffOnFresh, ), ), ); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 896adc53f520..b4012f23ad57 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -84,7 +84,11 @@ import { useComposerDraft, useComposerDraftSettings, } from "./use-composer-drafts"; -import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger"; +import { + isClearComposerCommand, + isRecapComposerCommand, + parseSideChatCommand, +} from "@t3tools/shared/composerTrigger"; import { compactionBlocksSteering, resolveComposerDispatchMode, @@ -647,16 +651,18 @@ export function useThreadComposerState(input: { } return null; } - // A redirect's text answers its approval, so /clear there is that text, not the command. + // A redirect's text answers its approval, so /clear or /recap there is that text, not the command. if ( beforeDispatch === undefined && selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.freshContext === true && thread.latestRun != null && attachments.length === 0 && draft.context === undefined && - isClearComposerCommand(text) + (isClearComposerCommand(text) || isRecapComposerCommand(text)) ) { - setFreshSessionPending(threadKey, true); + setFreshSessionPending(threadKey, true, { + withRecap: isRecapComposerCommand(text), + }); clearComposerDraftContent(threadKey); return null; } @@ -770,6 +776,8 @@ export function useThreadComposerState(input: { // A redirect belongs to the approval it answers; the armed session waits for the next message. const freshContext = beforeDispatch === undefined && isFreshSessionPending(threadKey); + const contextHandoffOnFresh = + freshContext && getComposerDraftSnapshot(threadKey).contextHandoffOnFresh === true; const metadata = makeQueuedMessageMetadata(); // A fresh session drops the context a compaction would summarize. Only a send the notice // announced compacts, since the notice's minute clock can trail the exact deadline. @@ -799,6 +807,7 @@ export function useThreadComposerState(input: { ? {} : { dispatchMode: followUpDispatchMode }), ...(freshContext ? { freshContext: true } : {}), + ...(contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); @@ -816,7 +825,9 @@ export function useThreadComposerState(input: { attachments: [], }); appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); - if (freshContext) setFreshSessionPending(threadKey, true); + if (freshContext) { + setFreshSessionPending(threadKey, true, { withRecap: contextHandoffOnFresh }); + } setThreadComposerError( threadKey, error instanceof Error ? error.message : "Failed to save the queued message.", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 960253adc398..724b882bd0e0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -3142,6 +3142,7 @@ const make = Effect.gen(function* () { attachments: [], mode, ...(input.freshContext ? { freshContext: true } : {}), + ...(input.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), createdBy: "agent", creationSource: "mcp", }) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 295bdde820e6..220df0b72459 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2251,7 +2251,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const followsCurrent = followsCurrentConversation(queuedMessage); const freshContext = queuedRun.freshContext === true; - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const contextHandoffOnFresh = freshContext && queuedRun.contextHandoffOnFresh === true; + const previousContextBoundary = contextBoundaryOrdinal(projection.runs); + const contextBoundary = freshContext ? ordinal : previousContextBoundary; // A run queued before a fresh run started is bound to the conversation that run left behind. const followsLatestContext = !freshContext && @@ -2401,11 +2403,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection, queuedProviderThread.id, ); - const coveredRuns = - freshContext || - canResumeAcrossInstances || - latestHandoffRun === undefined || - latestHandoffRun.providerInstanceId === queuedRun.providerInstanceId + const coveredRuns = contextHandoffOnFresh + ? projection.runs.filter( + (run) => + isHandoffSourceRun(run) && + run.id !== queuedRun.id && + run.ordinal >= previousContextBoundary && + run.ordinal < ordinal, + ) + : freshContext || + canResumeAcrossInstances || + latestHandoffRun === undefined || + latestHandoffRun.providerInstanceId === queuedRun.providerInstanceId ? [] : projection.runs.filter( (run) => @@ -2414,7 +2423,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio run.ordinal > (targetLastCompletedRun?.ordinal ?? 0) && run.ordinal <= latestHandoffRun.ordinal, ); - const needsFullContext = deliveryProviderThread.nativeThreadRef === null; + const needsFullContext = + deliveryProviderThread.nativeThreadRef === null || contextHandoffOnFresh; const legacyImportItems = projection.thread.historyOrigin === "v1_import" && contextBoundary === 0 ? yield* readHandoffItems(threadId, [null]) @@ -2645,6 +2655,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : { idleCompaction: queuedMessage.idleCompaction }), }), ...(freshContext ? { freshContext: true } : {}), + ...(contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), providerThreadId: queuedProviderThread.id, inputIntent: "queued_turn", status: "completed", @@ -2670,7 +2681,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentItemId: null, ordinal: ordinal * 100 - 1, status: "completed", - title: handoff === null ? "Imported context" : "Provider handoff", + title: + handoff === null + ? "Imported context" + : contextHandoffOnFresh + ? "Session handoff" + : "Provider handoff", startedAt: now, completedAt: now, updatedAt: now, @@ -6470,6 +6486,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode = { type: "start_immediately" }; } const freshContext = command.freshContext === true; + const contextHandoffOnFresh = freshContext && command.contextHandoffOnFresh === true; if ( freshContext && (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") @@ -6976,6 +6993,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : { usageLimitContinuationOfRunId: command.usageLimitContinuationOfRunId }), ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), ...(freshContext ? { freshContext: true } : {}), + ...(contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -7177,13 +7195,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } const ordinal = nextRunOrdinal(projection); const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const previousContextBoundary = contextBoundaryOrdinal(projection.runs); + const contextBoundary = freshContext ? ordinal : previousContextBoundary; const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); const legacyImportItems = projection.thread.historyOrigin === "v1_import" && contextBoundary === 0 ? yield* readHandoffItems(command.threadId, [null]) : []; + const seatHandoverCoveredRuns = contextHandoffOnFresh + ? projection.runs.filter( + (run) => isHandoffSourceRun(run) && run.ordinal >= previousContextBoundary, + ) + : []; const isProviderSwitch = activeProviderThread !== undefined && activeProviderThread.providerInstanceId !== modelSelection.instanceId; @@ -7282,6 +7306,73 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }) .pipe(mapDispatchError(command)) : null; + const seatHandoverSourceRun = seatHandoverCoveredRuns.at(-1); + const seatHandoverTransferId = + seatHandoverSourceRun === undefined + ? null + : yield* idAllocator.allocate + .contextTransfer({ + sourceThreadId: command.threadId, + targetThreadId: command.threadId, + type: "provider_handoff", + }) + .pipe(mapDispatchError(command)); + if (seatHandoverTransferId !== null) { + const seatHandoverCapabilities = yield* adapter.getCapabilities().pipe( + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: command.commandId, + providerInstanceId: modelSelection.instanceId, + cause, + }), + ), + ); + yield* mapDispatchError(command)( + commandPolicy.ensureContextHandoff({ + commandId: command.commandId, + threadId: command.threadId, + providerInstanceId: modelSelection.instanceId, + capabilities: seatHandoverCapabilities, + strategy: "full_thread_summary", + }), + ); + } + const seatHandoverHandoff = + seatHandoverTransferId === null || seatHandoverSourceRun === undefined + ? null + : yield* contextHandoffService + .prepareProviderHandoff({ + threadId: command.threadId, + targetRunId: runId, + transferId: seatHandoverTransferId, + fromProviderThreadIds: Array.from( + new Set( + seatHandoverCoveredRuns.flatMap((run) => + run.providerThreadId === null ? [] : [run.providerThreadId], + ), + ), + ), + toProviderThreadId: providerThreadId, + fromProviderInstanceId: seatHandoverSourceRun.providerInstanceId, + toProviderInstanceId: modelSelection.instanceId, + coveredRunOrdinals: { + from: seatHandoverCoveredRuns[0]!.ordinal, + to: seatHandoverCoveredRuns.at(-1)!.ordinal, + }, + strategy: "full_thread_summary", + items: [ + ...(previousContextBoundary === 0 ? legacyImportItems : []), + ...(yield* readHandoffItems( + command.threadId, + seatHandoverCoveredRuns.map((run) => run.id), + )), + ], + runs: projection.runs, + createdAt: now, + }) + .pipe(mapDispatchError(command)); + const activeFreshHandoff = seatHandoverHandoff ?? legacyImportHandoff; const providerThread: OrchestrationV2ProviderThread = reusableProviderThread === undefined ? { @@ -7296,7 +7387,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio status: "not_loaded", firstRunOrdinal: ordinal, lastRunOrdinal: ordinal, - handoffIds: legacyImportHandoff === null ? [] : [legacyImportHandoff.id], + handoffIds: + activeFreshHandoff === null + ? [] + : appendContextHandoffId( + appendContextHandoffId([], seatHandoverHandoff?.id ?? null), + legacyImportHandoff?.id ?? null, + ), forkedFrom: null, createdAt: now, updatedAt: now, @@ -7306,7 +7403,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerSessionId, lastRunOrdinal: ordinal, handoffIds: appendContextHandoffId( - reusableProviderThread.handoffIds, + appendContextHandoffId( + reusableProviderThread.handoffIds, + seatHandoverHandoff?.id ?? null, + ), legacyImportHandoff?.id ?? null, ), updatedAt: now, @@ -7352,7 +7452,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio startedAt: null, completedAt: null, checkpointId: null, - contextHandoffId: legacyImportHandoff?.id ?? null, + contextHandoffId: activeFreshHandoff?.id ?? null, ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), ...(command.restartContinuationOfRunId === undefined ? {} @@ -7366,6 +7466,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), ...wakeWorkStartedAt(projection, command), ...(freshContext ? { freshContext: true } : {}), + ...(contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -7448,6 +7549,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.context ? { context: command.context } : {}), attachments: command.attachments, ...(freshContext ? { freshContext: true } : {}), + ...(contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), ...(run.contextHandoffId === null ? modelSwitchSince(projection, modelSelection) : {}), }; const preparationTurnItem: OrchestrationV2TurnItem | null = @@ -7474,6 +7576,47 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio input: WORKSPACE_PREPARATION_INPUT, } : null; + const seatHandoverTurnItem: OrchestrationV2TurnItem | null = + seatHandoverHandoff === null || seatHandoverTransferId === null + ? null + : { + id: idAllocator.derive.runSignalTurnItem({ + runId, + signal: `context-handoff:${seatHandoverHandoff.id}`, + }), + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: Math.max(1, turnItem.ordinal - 1), + status: "completed", + title: "Session handoff", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "handoff", + contextHandoffId: seatHandoverHandoff.id, + fromProviderThreadIds: seatHandoverHandoff.fromProviderThreadIds, + toProviderThreadId: seatHandoverHandoff.toProviderThreadId, + fromProviderInstanceIds: Array.from( + new Set(seatHandoverCoveredRuns.map((run) => run.providerInstanceId)), + ), + toProviderInstanceId: modelSelection.instanceId, + fromModelSelections: Array.from( + new Map( + seatHandoverCoveredRuns.map((run) => [ + `${run.modelSelection.instanceId}\0${run.modelSelection.model}`, + run.modelSelection, + ]), + ).values(), + ), + toModel: modelSelection.model, + strategy: seatHandoverHandoff.strategy, + summary: seatHandoverHandoff.summaryText, + }; const emitEvent = emit(events, command); yield* emitEvent({ type: "provider-thread.updated", @@ -7483,6 +7626,44 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: providerThread, }); + if (seatHandoverHandoff !== null && seatHandoverTransferId !== null) { + yield* emitEvent({ + type: "context-transfer.created", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: seatHandoverTransferId, + type: "provider_handoff", + sourceThreadId: command.threadId, + targetThreadId: command.threadId, + sourcePoint: contextSourcePointForRun(projection, seatHandoverSourceRun!), + basePoint: null, + sourceProviderInstanceId: seatHandoverSourceRun!.providerInstanceId, + targetProviderInstanceId: modelSelection.instanceId, + targetRunId: runId, + status: "consumed", + resolution: { + strategy: "portable_context", + contextHandoffId: seatHandoverHandoff.id, + }, + createdBy: command.createdBy, + error: null, + createdAt: now, + updatedAt: now, + consumedAt: now, + }, + }); + yield* emitEvent({ + type: "context-handoff.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: seatHandoverHandoff, + }); + } if (legacyImportHandoff !== null) { yield* emitEvent({ type: "context-handoff.updated", @@ -7532,6 +7713,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: checkpointScope, }); } + if (seatHandoverTurnItem !== null) { + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: seatHandoverTurnItem, + }); + } yield* emitEvent({ type: "message.updated", threadId: command.threadId, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 26c00b243bcf..b3284b737933 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -149,6 +149,8 @@ export interface ThreadManagementSendInput { readonly mode: ThreadManagementSendMode; /** Start a new provider session; the orchestrator queues it instead of steering. */ readonly freshContext?: boolean; + /** With `freshContext`, deliver a durable full-thread recap into the new session. */ + readonly contextHandoffOnFresh?: boolean; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; } @@ -870,6 +872,7 @@ const make = Effect.gen(function* () { attachments: input.attachments, ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), ...(input.freshContext ? { freshContext: true } : {}), + ...(input.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), dispatchMode, ...(mode === "auto" ? { deliveryIntent: "auto" } : {}), createdBy: input.createdBy, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 728e4f2189c2..72bcb397c6dc 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2965,6 +2965,138 @@ describe("orchestration v2 provider switching", () => { ), ); + it.live("hands a fresh session a durable recap while keeping the same model", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("seat-handover"); + const capturedTurns = yield* Ref.make>([]); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + nativeThreadGeneration: yield* Ref.make(0), + }), + ]); + const threadId = ThreadId.make("thread:seat-handover"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = ( + name: string, + options: { + readonly freshContext?: boolean; + readonly contextHandoffOnFresh?: boolean; + } = {}, + ) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:seat-handover:${name}`), + threadId, + messageId: MessageId.make(`message:seat-handover:${name}`), + text: `Prompt ${name}`, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + ...options, + deliveryIntent: "auto", + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:seat-handover:create"), + threadId, + projectId: ProjectId.make("project:seat-handover"), + title: "Seat handover", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* send("alpha"); + yield* worker.drain(); + yield* waitForIdle(threadId); + const before = yield* orchestrator.getThreadProjection(threadId); + const priorModel = before.thread.modelSelection; + const priorNativeId = before.providerThreads.find( + (providerThread) => providerThread.id === before.thread.activeProviderThreadId, + )?.nativeThreadRef?.nativeId; + assert.isDefined(priorNativeId); + + yield* send("beta", { freshContext: true, contextHandoffOnFresh: true }); + yield* worker.drain(); + yield* waitForIdle(threadId); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const turns = yield* Ref.get(capturedTurns); + const freshRun = projection.runs.find( + (run) => run.userMessageId === "message:seat-handover:beta", + )!; + assert.isTrue(freshRun.freshContext); + assert.isTrue(freshRun.contextHandoffOnFresh); + assert.isDefined(freshRun.contextHandoffId); + assert.deepEqual(projection.thread.modelSelection, priorModel); + assert.isTrue( + projection.turnItems.some( + (item) => + item.type === "user_message" && + item.messageId === freshRun.userMessageId && + item.freshContext === true && + item.contextHandoffOnFresh === true, + ), + ); + + const handoff = projection.contextHandoffs.find( + (candidate) => candidate.id === freshRun.contextHandoffId, + ); + assert.equal(handoff?.strategy, "full_thread_summary"); + assert.include(handoff?.summaryText ?? "", "Prompt alpha"); + assert.isTrue( + projection.turnItems.some( + (item) => + item.type === "handoff" && + item.runId === freshRun.id && + item.title === "Session handoff" && + item.contextHandoffId === freshRun.contextHandoffId, + ), + ); + + const betaTurn = turns.find((turn) => turn.text.endsWith("Prompt beta"))!; + const alphaTurn = turns.find((turn) => turn.text.endsWith("Prompt alpha"))!; + assert.notEqual(betaTurn.providerThreadId, alphaTurn.providerThreadId); + assert.notEqual(betaTurn.nativeThreadId, alphaTurn.nativeThreadId); + assert.include(betaTurn.text, "Prompt alpha"); + assert.include(betaTurn.text, "Prompt beta"); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "seat-handover", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ), + ); + it.live("keeps the thread a queued fresh run reserved for that run", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 829c1155af66..1ac847f3cab3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -47,7 +47,7 @@ import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { freshSessionBannerItem } from "./chat/ComposerFreshSession"; -import { armFreshSession, disarmFreshSession, useFreshSessionArmed } from "./chat/freshSessionArms"; +import { armFreshSession, disarmFreshSession, useFreshSessionArm } from "./chat/freshSessionArms"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; import { useWorktreeSetup } from "./chat/useWorktreeSetup"; import { useChatShortcuts } from "./chat/useChatShortcuts"; @@ -2502,10 +2502,13 @@ export default function ChatView(props: ChatViewProps) { // While a question or approval owns the composer, its text answers that, not the agent. const freshSessionCommandAvailable = freshSessionAvailable && activePendingApproval === null && pendingUserInputs.length === 0; - const freshSessionArmed = useFreshSessionArmed(routeThreadKey); + const freshSessionArm = useFreshSessionArm(routeThreadKey); + const freshSessionArmed = freshSessionArm !== undefined; const toggleKeepFullHistory = () => setKeepFullHistory(routeThreadKey, !keepFullHistory); const toggleKeepFullHistoryStable = useStableCallback(toggleKeepFullHistory); - const armFreshSessionStable = useStableCallback(() => armFreshSession(routeThreadKey)); + const armFreshSessionStable = useStableCallback((options?: { readonly withRecap?: boolean }) => + armFreshSession(routeThreadKey, options), + ); const handleRestoreThreadBranch = () => { if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { @@ -2603,7 +2606,11 @@ export default function ChatView(props: ChatViewProps) { // An approval's feedback goes to that approval; the armed session waits for the next message. const freshSessionItems = freshSessionAvailable && freshSessionArmed && activePendingApproval === null - ? [freshSessionBannerItem(() => disarmFreshSession(routeThreadKey))] + ? [ + freshSessionBannerItem(() => disarmFreshSession(routeThreadKey), { + withRecap: freshSessionArm?.withRecap === true, + }), + ] : []; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 15c00fcba186..4afe0f115392 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -69,6 +69,7 @@ import { MessageSquareDashedIcon, LinkIcon, MessageSquareIcon, + NotebookPenIcon, PaletteIcon, RotateCcwIcon, SettingsIcon, @@ -80,6 +81,7 @@ import { TextSearchIcon, UserPlusIcon, } from "lucide-react"; +import { armFreshSession } from "./chat/freshSessionArms"; import { openThreadFind } from "./chat/threadFindStore"; import { dispatchPreviewAction } from "./preview/previewActionBus"; import { previewBridge } from "./preview/previewBridge"; @@ -1480,6 +1482,39 @@ function OpenCommandPaletteDialog(props: { }); } + if ( + activeThread !== null && + activeThreadKey !== null && + activeThreadServerConfig?.environment.capabilities.freshContext === true + ) { + actionItems.push({ + kind: "action", + value: "action:start-fresh-with-recap", + searchTerms: [ + "start fresh with recap", + "recap", + "handover", + "handoff", + "fresh", + "clear", + "compact", + "session", + "seat", + ], + title: "Start fresh with recap", + description: "New provider session with a written handoff. Distinct from Compact and Clear.", + icon: , + run: async () => { + armFreshSession(activeThreadKey, { withRecap: true }); + toastManager.add({ + type: "success", + title: "Fresh session with recap armed", + description: "Your next message starts a new session and hands over a written recap.", + }); + }, + }); + } + if (activeThread !== null) { const thread = activeThread; actionItems.push({ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fae55b8923d5..90ef437356b8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -379,8 +379,11 @@ export interface ChatComposerProps { onUsageLimitsCommand?: (() => void) | undefined; sideChatAvailable?: boolean; sideChatDisabledReason?: string | null; - /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */ - onFreshSession?: (() => void) | undefined; + /** + * Arms a fresh session for the next message; absent where `/clear` / `/recap` are unavailable. + * Pass `{ withRecap: true }` for Start fresh with recap. + */ + onFreshSession?: ((options?: { readonly withRecap?: boolean }) => void) | undefined; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -1899,13 +1902,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } - if (item.command === "clear") { + if (item.command === "clear" || item.command === "recap") { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); - props.onFreshSession?.(); + props.onFreshSession?.(item.command === "recap" ? { withRecap: true } : undefined); } return; } diff --git a/apps/web/src/components/chat/ComposerFreshSession.tsx b/apps/web/src/components/chat/ComposerFreshSession.tsx index 3ae2cafdc32b..29205934205d 100644 --- a/apps/web/src/components/chat/ComposerFreshSession.tsx +++ b/apps/web/src/components/chat/ComposerFreshSession.tsx @@ -1,9 +1,24 @@ -import { EraserIcon } from "lucide-react"; +import { EraserIcon, NotebookPenIcon } from "lucide-react"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; -/** Shown while `/clear` is armed; dismissing it keeps the current conversation. */ -export function freshSessionBannerItem(onUndo: () => void): ComposerBannerStackItem { +/** Shown while `/clear` or `/recap` is armed; dismissing it keeps the current conversation. */ +export function freshSessionBannerItem( + onUndo: () => void, + options?: { readonly withRecap?: boolean }, +): ComposerBannerStackItem { + if (options?.withRecap === true) { + return { + id: "fresh-session-recap", + variant: "info", + icon: , + title: "Your next message starts a fresh session with a recap", + description: + "Earlier messages stay visible. The new session gets a written handoff, not Compact (same session) or Clear (no history).", + dismissLabel: "Keep the current session", + onDismiss: onUndo, + }; + } return { id: "fresh-session", variant: "info", diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 21b27f7c6472..ef7778c42e68 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -253,7 +253,9 @@ export function UserMessageRow({ row }: { row: Extract - {item?.type === "user_message" && item.freshContext ? : null} + {item?.type === "user_message" && item.freshContext ? ( + + ) : null} {modelSwitch ? ( ) : null} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 40fffaf60841..827b73386b74 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -46,6 +46,7 @@ import { EraserIcon, GitForkIcon, MessageSquareIcon, + NotebookPenIcon, } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; @@ -598,9 +599,14 @@ function EnvironmentHandoffRow({ return ; } -/** Marks where `/clear` or an automation started a new provider session in this thread. */ -export function FreshSessionRow() { - return ; +/** Marks where `/clear`, `/recap`, or an automation started a new provider session in this thread. */ +export function FreshSessionRow(props?: { readonly withRecap?: boolean }) { + return ( + + ); } /** A same-harness model or effort change. Shows only what changed. */ diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index ba1aacb83de3..6804d589114c 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -12,7 +12,7 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; -/** Provider commands, `/side`, `/btw` and `/clear` work only at the message start. */ +/** Provider commands, `/side`, `/btw`, `/clear` and `/recap` work only at the message start. */ export function slashCommandItemsForPromptPosition( items: ReadonlyArray, isAtPromptStart: boolean, diff --git a/apps/web/src/components/chat/freshSessionArms.ts b/apps/web/src/components/chat/freshSessionArms.ts index cd2dc9b52e48..f91e7c5d0010 100644 --- a/apps/web/src/components/chat/freshSessionArms.ts +++ b/apps/web/src/components/chat/freshSessionArms.ts @@ -3,28 +3,49 @@ import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "../../rpc/atomRegistry"; -/** Threads whose next message starts a fresh provider session, keyed by thread, valued by when it was armed. */ -const freshSessionArmsAtom = Atom.make>>({}).pipe(Atom.keepAlive); +export type FreshSessionArm = { + readonly at: number; + /** When true, the fresh session gets a durable written recap of earlier history. */ + readonly withRecap: boolean; +}; -export function armFreshSession(threadKey: string): void { +/** Threads whose next message starts a fresh provider session, keyed by thread. */ +const freshSessionArmsAtom = Atom.make>>({}).pipe( + Atom.keepAlive, +); + +export function armFreshSession( + threadKey: string, + options?: { readonly withRecap?: boolean }, +): void { appAtomRegistry.set(freshSessionArmsAtom, { ...appAtomRegistry.get(freshSessionArmsAtom), - [threadKey]: Date.now(), + [threadKey]: { at: Date.now(), withRecap: options?.withRecap === true }, }); } /** With `arm`, clears only that arm, so a send never consumes one made while it was in flight. */ -export function disarmFreshSession(threadKey: string, arm?: number): void { +export function disarmFreshSession(threadKey: string, arm?: FreshSessionArm): void { const current = appAtomRegistry.get(freshSessionArmsAtom); - if (current[threadKey] === undefined || (arm !== undefined && current[threadKey] !== arm)) return; + const existing = current[threadKey]; + if ( + existing === undefined || + (arm !== undefined && (existing.at !== arm.at || existing.withRecap !== arm.withRecap)) + ) { + return; + } const { [threadKey]: _removed, ...rest } = current; appAtomRegistry.set(freshSessionArmsAtom, rest); } -export function readFreshSessionArm(threadKey: string): number | undefined { +export function readFreshSessionArm(threadKey: string): FreshSessionArm | undefined { return appAtomRegistry.get(freshSessionArmsAtom)[threadKey]; } +export function useFreshSessionArm(threadKey: string): FreshSessionArm | undefined { + return useAtomValue(freshSessionArmsAtom)[threadKey]; +} + export function useFreshSessionArmed(threadKey: string): boolean { - return useAtomValue(freshSessionArmsAtom)[threadKey] !== undefined; + return useFreshSessionArm(threadKey) !== undefined; } diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 7620bf861102..539f2dc66146 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -263,7 +263,15 @@ export function useComposerMenuItems({ type: "slash-command", command: "clear", label: "/clear", - description: "Start a fresh session; earlier messages stay visible", + description: "Start a fresh session without earlier context", + }, + { + id: "slash:recap", + type: "slash-command", + command: "recap", + label: "/recap", + description: + "Start fresh with recap: new session, written handoff of earlier messages", }, ] as const) : []), diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 44585db4c175..fa69f383a8f2 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1,4 +1,8 @@ -import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger"; +import { + isClearComposerCommand, + isRecapComposerCommand, + parseSideChatCommand, +} from "@t3tools/shared/composerTrigger"; import { armFreshSession, disarmFreshSession, readFreshSessionArm } from "./freshSessionArms"; import { mergeDirectPreviewAnnotation, @@ -698,8 +702,14 @@ export function useComposerSend(input: ComposerSendInput) { composerReviewComments.length === 0 && composerThreadContexts.length === 0 && composerEnvironmentContexts.length === 0; - if (freshSessionAvailable && composerHoldsOnlyText && isClearComposerCommand(trimmed)) { - armFreshSession(routeThreadKey); + if ( + freshSessionAvailable && + composerHoldsOnlyText && + (isClearComposerCommand(trimmed) || isRecapComposerCommand(trimmed)) + ) { + armFreshSession(routeThreadKey, { + withRecap: isRecapComposerCommand(trimmed), + }); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -1273,6 +1283,7 @@ export function useComposerSend(input: ComposerSendInput) { interactionMode: DEFAULT_INTERACTION_MODE, dispatchMode: turnDispatchMode, ...(freshArm === undefined ? {} : { freshContext: true }), + ...(freshArm?.withRecap === true ? { contextHandoffOnFresh: true } : {}), ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 37fef411897f..d57c14559327 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -18,7 +18,7 @@ import { import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; -export type ComposerSlashCommand = "model" | "side" | "btw" | "clear"; +export type ComposerSlashCommand = "model" | "side" | "btw" | "clear" | "recap"; export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; export interface ComposerTrigger { diff --git a/docs/internals/context-handoffs.md b/docs/internals/context-handoffs.md index 965a4623f796..5615c30dccce 100644 --- a/docs/internals/context-handoffs.md +++ b/docs/internals/context-handoffs.md @@ -10,8 +10,15 @@ changes, checkpoints, and prior handoffs. It skips other turn item types. For te carry the filename, while checkpoints carry only the number of files. Provider handoffs use either the full eligible app history or the delta since that provider last -participated. Fork merge-back handoffs summarize eligible delta items from the child. Neither path -reconstructs provider-native session state, tool state, approvals, or omitted text. +participated. Fork merge-back handoffs summarize eligible delta items from the child. A seat +handover (`freshContext` with `contextHandoffOnFresh`, UI: Start fresh with recap / `/recap`) also +builds a full-thread summary into a new provider session. That is not Compact (same native session) +and not Clear / `/clear` (fresh with no written history). Neither path reconstructs provider-native +session state, tool state, approvals, or omitted text. + +Honest restore language (restart recovery) treats a fresh start as fresh, not resumed. Cross-driver +runtime seat switches reuse portable handoffs for a different harness; this same-seat handover keeps +the thread and model selection and only rotates the provider session. Legacy v1 continuation uses a separate algorithm. It considers only user and assistant messages, walks backward from the newest message, and builds a transcript suffix within a 32,000-character diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 894a961e018b..695e77b041b7 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -193,6 +193,8 @@ export interface StartThreadTurnInput extends ThreadCommandInput { readonly freshContext?: boolean; /** Defaults to true. Callers with their own retry queue opt out so a dead socket fails fast. */ readonly resendAfterSocketLoss?: boolean; + /** With `freshContext`, deliver a durable full-thread recap into the new session. */ + readonly contextHandoffOnFresh?: boolean; } export interface InterruptThreadTurnInput extends ThreadCommandInput { @@ -805,6 +807,7 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( ? {} : { sourcePlanRef: input.sourceProposedPlan }), ...(input.freshContext ? { freshContext: true } : {}), + ...(input.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), dispatchMode: { type: "start_immediately" }, }, resendAfterSocketLoss, @@ -835,6 +838,7 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( ? {} : { sourcePlanRef: input.sourceProposedPlan }), ...(input.freshContext ? { freshContext: true } : {}), + ...(input.contextHandoffOnFresh ? { contextHandoffOnFresh: true } : {}), ...(requestedMode !== "queue" ? { deliveryIntent: requestedMode } : {}), dispatchMode, }, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 4cb4984b74ba..2cd85e59335a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -719,6 +719,11 @@ export const OrchestrationV2Run = Schema.Struct({ workspacePreparation: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), /** Starts a new provider conversation; earlier runs stay visible but never reach the provider. */ freshContext: Schema.optional(Schema.Boolean), + /** + * With `freshContext`, also prepare a full-thread portable handoff of earlier history so the new + * session keeps a durable written recap. Ignored unless `freshContext` is true. + */ + contextHandoffOnFresh: Schema.optional(Schema.Boolean), }); export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; @@ -1654,6 +1659,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), workflowScript: OrchestrationV2ConversationMessage.fields.workflowScript, freshContext: OrchestrationV2Run.fields.freshContext, + contextHandoffOnFresh: OrchestrationV2Run.fields.contextHandoffOnFresh, inputIntent: OrchestrationV2UserMessageInputIntent, text: Schema.String, /** Same as the text of message `messageId`. */ @@ -2503,6 +2509,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), workflowScript: OrchestrationV2ConversationMessage.fields.workflowScript, freshContext: OrchestrationV2Run.fields.freshContext, + contextHandoffOnFresh: OrchestrationV2Run.fields.contextHandoffOnFresh, inputIntent: OrchestrationV2UserMessageInputIntent, text: Schema.String, context: Schema.optional(OrchestrationMessageContext), @@ -3218,6 +3225,11 @@ export const OrchestrationV2Command = Schema.Union([ idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), /** Start a new provider session without the thread's earlier context. Never steers. */ freshContext: Schema.optional(Schema.Boolean), + /** + * With `freshContext`, hand the seat to the new session with a durable full-thread recap. + * Ignored unless `freshContext` is true. Unlike `/clear`, earlier history is summarized in. + */ + contextHandoffOnFresh: Schema.optional(Schema.Boolean), commandId: CommandId, threadId: ThreadId, messageId: MessageId, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d1bb9961a0c6..ba51c57e9718 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -514,10 +514,11 @@ export const OrchestratorMcpThreadSendInput = Schema.Struct({ mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), freshContext: Schema.optional( Schema.Boolean.annotate({ - description: - "Start a new provider session without the thread's earlier context, like /clear. Never steers.", + description: "Fresh session without earlier context like /clear.", }), ), + /** With `freshContext`, include a written recap (`/recap`) in the new session. */ + contextHandoffOnFresh: Schema.optional(Schema.Boolean), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpThreadSendInput = typeof OrchestratorMcpThreadSendInput.Type; diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 72ff3db848cf..3b8791e0f608 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { detectComposerTrigger, + isClearComposerCommand, + isRecapComposerCommand, parseSideChatCommand, serializeComposerFileLink, } from "./composerTrigger.ts"; @@ -111,3 +113,16 @@ describe("parseSideChatCommand", () => { expect(parseSideChatCommand("ask /side later")).toBeNull(); }); }); + +describe("fresh session slash commands", () => { + it("recognizes bare /clear and /recap", () => { + expect(isClearComposerCommand("/clear")).toBe(true); + expect(isClearComposerCommand(" /CLEAR ")).toBe(true); + expect(isClearComposerCommand("/clear keep notes")).toBe(false); + expect(isRecapComposerCommand("/recap")).toBe(true); + expect(isRecapComposerCommand(" /RECAP ")).toBe(true); + expect(isRecapComposerCommand("/recap keep notes")).toBe(false); + expect(isRecapComposerCommand("/clear")).toBe(false); + expect(isClearComposerCommand("/recap")).toBe(false); + }); +}); diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 80292492154a..7de1643b163b 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -175,6 +175,14 @@ export function isClearComposerCommand(text: string): boolean { return /^\/clear\s*$/i.test(text.trim()); } +/** + * `/recap` as the whole message arms a fresh session that keeps a durable written handoff + * of earlier history (Start fresh with recap). Distinct from `/clear` and `/compact`. + */ +export function isRecapComposerCommand(text: string): boolean { + return /^\/recap\s*$/i.test(text.trim()); +} + /** `/side [prompt]`, or its alias `/btw [prompt]`, as the whole message. */ export function parseSideChatCommand(text: string): { prompt: string } | null { const match = /^\/(?:side|btw)(?:\s+([\s\S]*))?$/i.exec(text.trim());