| author | |
| committer | |
| log | 535557b3f785629e6f48cad40c5a4b78b3b3c5d6 |
| tree | 7384a53e54752305fcc8062abd0d4fe44aa6b814 |
| parent | 1eb36b45ef7aa5c028380afb7266bcabf258a55e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>35 files changed, 646 insertions(+), 114 deletions(-)
apps/mobile/src/features/threads/use-composer-command-menu.ts+2-2| ... | ... | @@ -127,8 +127,8 @@ export function useComposerCommandMenu({ |
| 127 | 127 | // Codex feedback uploads an existing thread's session and logs. |
| 128 | 128 | if ( |
| 129 | 129 | !hasThread && |
| 130 | selectedProviderStatus?.driver === "codex" && | |
| 131 | command.name === "feedback" | |
| 130 | (command.name === "compact" || | |
| 131 | (selectedProviderStatus?.driver === "codex" && command.name === "feedback")) | |
| 132 | 132 | ) { |
| 133 | 133 | continue; |
| 134 | 134 | } |
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1| ... | ... | @@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll( |
| 110 | 110 | Layer.succeed(ProviderService.ProviderService, { |
| 111 | 111 | startSession: () => Effect.die("unused"), |
| 112 | 112 | sendTurn: () => Effect.die("unused"), |
| 113 | compactThread: () => Effect.die("unused"), | |
| 113 | 114 | interruptTurn: () => Effect.die("unused"), |
| 114 | 115 | respondToRequest: () => Effect.die("unused"), |
| 115 | 116 | respondToUserInput: () => Effect.die("unused"), |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1| ... | ... | @@ -107,6 +107,7 @@ function createProviderServiceHarness( |
| 107 | 107 | const service: ProviderServiceShape = { |
| 108 | 108 | startSession: () => unsupported(), |
| 109 | 109 | sendTurn: () => unsupported(), |
| 110 | compactThread: () => unsupported(), | |
| 110 | 111 | interruptTurn: () => unsupported(), |
| 111 | 112 | respondToRequest: () => unsupported(), |
| 112 | 113 | respondToUserInput: () => unsupported(), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+15| ... | ... | @@ -1275,6 +1275,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1275 | 1275 | return; |
| 1276 | 1276 | } |
| 1277 | 1277 | |
| 1278 | case "thread.activity-appended": | |
| 1279 | if (event.payload.activity.kind !== "provider.turn.start.failed") return; | |
| 1280 | const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId( | |
| 1281 | event.payload, | |
| 1282 | ); | |
| 1283 | if ( | |
| 1284 | Option.isNone(pendingTurnStart) || | |
| 1285 | String(pendingTurnStart.value.messageId) !== | |
| 1286 | extractActivityRequestId(event.payload.activity.payload) | |
| 1287 | ) { | |
| 1288 | return; | |
| 1289 | } | |
| 1290 | yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload); | |
| 1291 | return; | |
| 1292 | ||
| 1278 | 1293 | case "thread.session-set": { |
| 1279 | 1294 | const turnId = event.payload.session.activeTurnId; |
| 1280 | 1295 | if (turnId === null || event.payload.session.status !== "running") { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+24| ... | ... | @@ -254,6 +254,7 @@ describe("ProviderCommandReactor", () => { |
| 254 | 254 | turnId: asTurnId("turn-1"), |
| 255 | 255 | }), |
| 256 | 256 | ); |
| 257 | const compactThread = vi.fn((_: ThreadId) => Effect.void); | |
| 257 | 258 | const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); |
| 258 | 259 | const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void); |
| 259 | 260 | const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void); |
| ... | ... | @@ -339,6 +340,7 @@ describe("ProviderCommandReactor", () => { |
| 339 | 340 | const service: ProviderServiceShape = { |
| 340 | 341 | startSession: startSession as ProviderServiceShape["startSession"], |
| 341 | 342 | sendTurn: sendTurn as ProviderServiceShape["sendTurn"], |
| 343 | compactThread, | |
| 342 | 344 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], |
| 343 | 345 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], |
| 344 | 346 | respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], |
| ... | ... | @@ -533,6 +535,7 @@ describe("ProviderCommandReactor", () => { |
| 533 | 535 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), |
| 534 | 536 | startSession, |
| 535 | 537 | sendTurn, |
| 538 | compactThread, | |
| 536 | 539 | interruptTurn, |
| 537 | 540 | respondToRequest, |
| 538 | 541 | respondToUserInput, |
| ... | ... | @@ -632,6 +635,27 @@ describe("ProviderCommandReactor", () => { |
| 632 | 635 | }), |
| 633 | 636 | ); |
| 634 | 637 | |
| 638 | effectIt.effect("rejects /compact without conversation context", () => | |
| 639 | Effect.gen(function* () { | |
| 640 | const harness = yield* Effect.promise(() => createHarness()); | |
| 641 | yield* harness.engine.dispatch({ | |
| 642 | type: "thread.turn.start", | |
| 643 | commandId: CommandId.make("cmd-empty-compact"), | |
| 644 | threadId: ThreadId.make("thread-1"), | |
| 645 | message: { | |
| 646 | messageId: asMessageId("user-message-empty-compact"), | |
| 647 | role: "user", | |
| 648 | text: "/compact", | |
| 649 | attachments: [], | |
| 650 | }, | |
| 651 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 652 | runtimeMode: "approval-required", | |
| 653 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 654 | }); | |
| 655 | yield* Effect.promise(() => harness.drain()); | |
| 656 | expect(harness.compactThread).not.toHaveBeenCalled(); | |
| 657 | }), | |
| 658 | ); | |
| 635 | 659 | effectIt.effect("projects starting before a slow provider session finishes", () => |
| 636 | 660 | Effect.gen(function* () { |
| 637 | 661 | const releaseStart = yield* Deferred.make<void>(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+96-21| ... | ... | @@ -30,7 +30,10 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 30 | 30 | |
| 31 | 31 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 32 | 32 | import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; |
| 33 | import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; | |
| 33 | import { | |
| 34 | ProviderAdapterRequestError, | |
| 35 | ProviderAdapterValidationError, | |
| 36 | } from "../../provider/Errors.ts"; | |
| 34 | 37 | import type { ProviderServiceError } from "../../provider/Errors.ts"; |
| 35 | 38 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 36 | 39 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| ... | ... | @@ -50,6 +53,7 @@ import { |
| 50 | 53 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 51 | 54 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 52 | 55 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); |
| 56 | const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); | |
| 53 | 57 | const isProviderDriverKind = Schema.is(ProviderDriverKind); |
| 54 | 58 | |
| 55 | 59 | type ProviderIntentEvent = Extract< |
| ... | ... | @@ -72,6 +76,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined |
| 72 | 76 | return normalized && normalized.length > 0 ? normalized : undefined; |
| 73 | 77 | } |
| 74 | 78 | |
| 79 | const isCompactCommandMessage = (message: ThreadTitleMessage): boolean => | |
| 80 | message.role === "user" && | |
| 81 | (message.attachments?.length ?? 0) === 0 && | |
| 82 | message.text.trim().toLowerCase() === "/compact"; | |
| 75 | 83 | function mapProviderSessionStatusToOrchestrationStatus( |
| 76 | 84 | status: "connecting" | "ready" | "running" | "error" | "closed", |
| 77 | 85 | ): OrchestrationSession["status"] { |
| ... | ... | @@ -332,6 +340,7 @@ const make = Effect.gen(function* () { |
| 332 | 340 | ); |
| 333 | 341 | |
| 334 | 342 | const threadModelSelections = new Map<string, ModelSelection>(); |
| 343 | const compactingThreadIds = new Set<ThreadId>(); | |
| 335 | 344 | |
| 336 | 345 | const appendProviderFailureActivity = (input: { |
| 337 | 346 | readonly threadId: ThreadId; |
| ... | ... | @@ -375,11 +384,11 @@ const make = Effect.gen(function* () { |
| 375 | 384 | |
| 376 | 385 | const formatFailureDetail = (cause: Cause.Cause<unknown>): string => { |
| 377 | 386 | const failReason = cause.reasons.find(Cause.isFailReason); |
| 378 | const providerError = isProviderAdapterRequestError(failReason?.error) | |
| 379 | ? failReason.error | |
| 380 | : undefined; | |
| 381 | if (providerError) { | |
| 382 | return providerError.detail; | |
| 387 | if (isProviderAdapterRequestError(failReason?.error)) { | |
| 388 | return failReason.error.detail; | |
| 389 | } | |
| 390 | if (isProviderAdapterValidationError(failReason?.error)) { | |
| 391 | return failReason.error.issue; | |
| 383 | 392 | } |
| 384 | 393 | return Cause.pretty(cause); |
| 385 | 394 | }; |
| ... | ... | @@ -1137,7 +1146,6 @@ const make = Effect.gen(function* () { |
| 1137 | 1146 | if (!thread) { |
| 1138 | 1147 | return; |
| 1139 | 1148 | } |
| 1140 | ||
| 1141 | 1149 | const message = thread.messages.find((entry) => entry.id === event.payload.messageId); |
| 1142 | 1150 | if (!message || message.role !== "user") { |
| 1143 | 1151 | yield* appendProviderFailureActivity({ |
| ... | ... | @@ -1147,15 +1155,28 @@ const make = Effect.gen(function* () { |
| 1147 | 1155 | detail: `User message '${event.payload.messageId}' was not found for turn start request.`, |
| 1148 | 1156 | turnId: null, |
| 1149 | 1157 | createdAt: event.payload.createdAt, |
| 1158 | requestId: event.payload.messageId, | |
| 1150 | 1159 | }); |
| 1151 | 1160 | return; |
| 1152 | 1161 | } |
| 1162 | const appendTurnStartFailure = (summary: string, detail: string) => | |
| 1163 | appendProviderFailureActivity({ | |
| 1164 | threadId: event.payload.threadId, | |
| 1165 | kind: "provider.turn.start.failed", | |
| 1166 | summary, | |
| 1167 | detail, | |
| 1168 | turnId: null, | |
| 1169 | createdAt: event.payload.createdAt, | |
| 1170 | requestId: event.payload.messageId, | |
| 1171 | }); | |
| 1153 | 1172 | |
| 1154 | 1173 | yield* ensureThreadWorktree(thread); |
| 1155 | 1174 | |
| 1156 | const isFirstUserMessageTurn = | |
| 1157 | thread.messages.filter((entry) => entry.role === "user").length === 1; | |
| 1158 | if (isFirstUserMessageTurn) { | |
| 1175 | const isCompactCommand = isCompactCommandMessage(message); | |
| 1176 | const nonCompactUserMessageCount = thread.messages.filter( | |
| 1177 | (entry) => entry.role === "user" && !isCompactCommandMessage(entry), | |
| 1178 | ).length; | |
| 1179 | if (nonCompactUserMessageCount === 1 && !isCompactCommand) { | |
| 1159 | 1180 | const project = yield* resolveProject(thread.projectId); |
| 1160 | 1181 | const generationCwd = |
| 1161 | 1182 | resolveThreadWorkspaceCwd({ |
| ... | ... | @@ -1194,16 +1215,7 @@ const make = Effect.gen(function* () { |
| 1194 | 1215 | detail, |
| 1195 | 1216 | createdAt: event.payload.createdAt, |
| 1196 | 1217 | }).pipe( |
| 1197 | Effect.flatMap(() => | |
| 1198 | appendProviderFailureActivity({ | |
| 1199 | threadId: event.payload.threadId, | |
| 1200 | kind: "provider.turn.start.failed", | |
| 1201 | summary: "Provider turn start failed", | |
| 1202 | detail, | |
| 1203 | turnId: null, | |
| 1204 | createdAt: event.payload.createdAt, | |
| 1205 | }), | |
| 1206 | ), | |
| 1218 | Effect.flatMap(() => appendTurnStartFailure("Provider turn start failed", detail)), | |
| 1207 | 1219 | Effect.asVoid, |
| 1208 | 1220 | ); |
| 1209 | 1221 | }; |
| ... | ... | @@ -1220,6 +1232,69 @@ const make = Effect.gen(function* () { |
| 1220 | 1232 | ), |
| 1221 | 1233 | ); |
| 1222 | 1234 | |
| 1235 | const handleCompactionFailure = (cause: Cause.Cause<unknown>) => { | |
| 1236 | if (Cause.hasInterruptsOnly(cause)) { | |
| 1237 | return Effect.void; | |
| 1238 | } | |
| 1239 | const detail = formatFailureDetail(cause); | |
| 1240 | return appendTurnStartFailure("Context compaction failed", detail).pipe(Effect.asVoid); | |
| 1241 | }; | |
| 1242 | const recoverCompactionFailure = (cause: Cause.Cause<unknown>) => | |
| 1243 | handleCompactionFailure(cause).pipe( | |
| 1244 | Effect.catchCause((recoveryCause) => | |
| 1245 | Effect.logWarning("provider command reactor failed to recover compaction failure", { | |
| 1246 | eventType: event.type, | |
| 1247 | threadId: event.payload.threadId, | |
| 1248 | cause: Cause.pretty(recoveryCause), | |
| 1249 | originalCause: Cause.pretty(cause), | |
| 1250 | }), | |
| 1251 | ), | |
| 1252 | ); | |
| 1253 | if (isCompactCommand) { | |
| 1254 | if (nonCompactUserMessageCount === 0) { | |
| 1255 | return yield* appendTurnStartFailure( | |
| 1256 | "Context compaction failed", | |
| 1257 | "Context compaction requires an existing conversation.", | |
| 1258 | ); | |
| 1259 | } | |
| 1260 | const latestThread = yield* resolveThread(event.payload.threadId); | |
| 1261 | if ( | |
| 1262 | compactingThreadIds.has(event.payload.threadId) || | |
| 1263 | latestThread?.session?.status === "starting" || | |
| 1264 | latestThread?.session?.status === "running" | |
| 1265 | ) { | |
| 1266 | yield* appendTurnStartFailure( | |
| 1267 | "Context compaction failed", | |
| 1268 | "Context compaction is unavailable while a provider turn is running.", | |
| 1269 | ); | |
| 1270 | return; | |
| 1271 | } | |
| 1272 | compactingThreadIds.add(event.payload.threadId); | |
| 1273 | yield* Effect.gen(function* () { | |
| 1274 | yield* ensureSessionForThread( | |
| 1275 | event.payload.threadId, | |
| 1276 | event.payload.createdAt, | |
| 1277 | event.payload.modelSelection !== undefined | |
| 1278 | ? { modelSelection: event.payload.modelSelection } | |
| 1279 | : undefined, | |
| 1280 | ); | |
| 1281 | if (event.payload.modelSelection !== undefined) { | |
| 1282 | threadModelSelections.set(event.payload.threadId, event.payload.modelSelection); | |
| 1283 | } | |
| 1284 | yield* providerService.compactThread(event.payload.threadId, event.payload.modelSelection); | |
| 1285 | }).pipe( | |
| 1286 | Effect.catchCause(recoverCompactionFailure), | |
| 1287 | Effect.ensuring(Effect.sync(() => void compactingThreadIds.delete(event.payload.threadId))), | |
| 1288 | Effect.forkScoped, | |
| 1289 | ); | |
| 1290 | return; | |
| 1291 | } | |
| 1292 | if (compactingThreadIds.has(event.payload.threadId)) { | |
| 1293 | return yield* appendTurnStartFailure( | |
| 1294 | "Provider turn start failed", | |
| 1295 | "Wait for context compaction to finish before sending another message.", | |
| 1296 | ); | |
| 1297 | } | |
| 1223 | 1298 | const sendTurnRequest = yield* buildSendTurnRequestForThread({ |
| 1224 | 1299 | threadId: event.payload.threadId, |
| 1225 | 1300 | messageText: message.text, |
| ... | ... | @@ -1240,7 +1315,7 @@ const make = Effect.gen(function* () { |
| 1240 | 1315 | |
| 1241 | 1316 | yield* providerService |
| 1242 | 1317 | .sendTurn(sendTurnRequest.value) |
| 1243 | .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); | |
| 1318 | .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); | |
| 1244 | 1319 | }); |
| 1245 | 1320 | |
| 1246 | 1321 | const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+4-1| ... | ... | @@ -105,6 +105,7 @@ function createProviderServiceHarness() { |
| 105 | 105 | const service: ProviderServiceShape = { |
| 106 | 106 | startSession: () => unsupported(), |
| 107 | 107 | sendTurn: () => unsupported(), |
| 108 | compactThread: () => unsupported(), | |
| 108 | 109 | interruptTurn: () => unsupported(), |
| 109 | 110 | respondToRequest: () => unsupported(), |
| 110 | 111 | respondToUserInput: () => unsupported(), |
| ... | ... | @@ -3221,6 +3222,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 3221 | 3222 | turnId: asTurnId("turn-1"), |
| 3222 | 3223 | payload: { |
| 3223 | 3224 | state: "compacted", |
| 3225 | beforeTokens: 899_000, | |
| 3226 | afterTokens: 19_000, | |
| 3224 | 3227 | detail: { source: "provider" }, |
| 3225 | 3228 | }, |
| 3226 | 3229 | }); |
| ... | ... | @@ -3234,7 +3237,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 3234 | 3237 | const activity = thread.activities.find( |
| 3235 | 3238 | (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction", |
| 3236 | 3239 | ); |
| 3237 | expect(activity?.summary).toBe("Context compacted"); | |
| 3240 | expect(activity?.summary).toBe("Compacted context 899K → 19K tokens"); | |
| 3238 | 3241 | expect(activity?.tone).toBe("info"); |
| 3239 | 3242 | }); |
| 3240 | 3243 |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+10-1| ... | ... | @@ -28,6 +28,7 @@ import * as Layer from "effect/Layer"; |
| 28 | 28 | import * as Option from "effect/Option"; |
| 29 | 29 | import * as Stream from "effect/Stream"; |
| 30 | 30 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 31 | import { formatTokens } from "@t3tools/shared/usageFormat"; | |
| 31 | 32 | |
| 32 | 33 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 33 | 34 | import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; |
| ... | ... | @@ -753,15 +754,23 @@ export function runtimeEventToActivities( |
| 753 | 754 | return []; |
| 754 | 755 | } |
| 755 | 756 | |
| 757 | const beforeTokens = event.payload.beforeTokens; | |
| 758 | const afterTokens = event.payload.afterTokens; | |
| 759 | const summary = | |
| 760 | beforeTokens !== undefined && afterTokens !== undefined | |
| 761 | ? `Compacted context ${formatTokens(beforeTokens)} → ${formatTokens(afterTokens)} tokens` | |
| 762 | : "Context compacted"; | |
| 756 | 763 | return [ |
| 757 | 764 | { |
| 758 | 765 | id: event.eventId, |
| 759 | 766 | createdAt: event.createdAt, |
| 760 | 767 | tone: "info", |
| 761 | 768 | kind: "context-compaction", |
| 762 | summary: "Context compacted", | |
| 769 | summary, | |
| 763 | 770 | payload: { |
| 764 | 771 | state: event.payload.state, |
| 772 | ...(beforeTokens !== undefined ? { beforeTokens } : {}), | |
| 773 | ...(afterTokens !== undefined ? { afterTokens } : {}), | |
| 765 | 774 | ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), |
| 766 | 775 | }, |
| 767 | 776 | turnId: toTurnId(event.turnId) ?? null, |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+7-6| ... | ... | @@ -729,12 +729,7 @@ describe("ClaudeAdapterLive", () => { |
| 729 | 729 | runtimeMode: "full-access", |
| 730 | 730 | }); |
| 731 | 731 | |
| 732 | yield* adapter.sendTurn({ | |
| 733 | threadId: session.threadId, | |
| 734 | input: "/compact", | |
| 735 | attachments: [], | |
| 736 | modelSelection, | |
| 737 | }); | |
| 732 | yield* adapter.compactThread!(session.threadId, modelSelection); | |
| 738 | 733 | |
| 739 | 734 | const promptText = yield* Effect.promise(() => |
| 740 | 735 | readFirstPromptText(harness.getLastCreateQueryInput()), |
| ... | ... | @@ -2232,6 +2227,12 @@ describe("ClaudeAdapterLive", () => { |
| 2232 | 2227 | } as unknown as SDKMessage); |
| 2233 | 2228 | |
| 2234 | 2229 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); |
| 2230 | const compactionEvent = runtimeEvents.find( | |
| 2231 | (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", | |
| 2232 | ); | |
| 2233 | assert.ok(compactionEvent?.type === "thread.state.changed"); | |
| 2234 | assert.equal(compactionEvent.payload.beforeTokens, 200); | |
| 2235 | assert.equal(compactionEvent.payload.afterTokens, 40); | |
| 2235 | 2236 | const finalUsageEvent = runtimeEvents.findLast( |
| 2236 | 2237 | (event) => event.type === "thread.token-usage.updated", |
| 2237 | 2238 | ); |
apps/server/src/provider/Layers/ClaudeAdapter.ts+28-13| ... | ... | @@ -3198,32 +3198,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3198 | 3198 | }, |
| 3199 | 3199 | }); |
| 3200 | 3200 | return; |
| 3201 | case "compact_boundary": | |
| 3201 | case "compact_boundary": { | |
| 3202 | 3202 | if (context.turnState) { |
| 3203 | 3203 | context.turnState.latestAssistantUsage = undefined; |
| 3204 | 3204 | context.turnState.compactedSinceLatestAssistantUsage = true; |
| 3205 | 3205 | } |
| 3206 | yield* emitThreadTokenUsage( | |
| 3207 | context, | |
| 3208 | compactBoundaryTokenUsageSnapshot( | |
| 3209 | message as unknown as Record<string, unknown>, | |
| 3210 | context.lastKnownContextWindow, | |
| 3211 | context.lastKnownTotalProcessedTokens, | |
| 3212 | ), | |
| 3213 | { | |
| 3214 | rawMethod: "claude/system/compact_boundary", | |
| 3215 | rawPayload: message, | |
| 3216 | }, | |
| 3206 | const compactedUsage = compactBoundaryTokenUsageSnapshot( | |
| 3207 | message as unknown as Record<string, unknown>, | |
| 3208 | context.lastKnownContextWindow, | |
| 3209 | context.lastKnownTotalProcessedTokens, | |
| 3217 | 3210 | ); |
| 3211 | yield* emitThreadTokenUsage(context, compactedUsage, { | |
| 3212 | rawMethod: "claude/system/compact_boundary", | |
| 3213 | rawPayload: message, | |
| 3214 | }); | |
| 3218 | 3215 | yield* offerRuntimeEvent({ |
| 3219 | 3216 | ...base, |
| 3220 | 3217 | type: "thread.state.changed", |
| 3221 | 3218 | payload: { |
| 3222 | 3219 | state: "compacted", |
| 3220 | ...(compactedUsage?.lastUsedTokens !== undefined | |
| 3221 | ? { beforeTokens: compactedUsage.lastUsedTokens } | |
| 3222 | : {}), | |
| 3223 | ...(compactedUsage?.usedTokens !== undefined | |
| 3224 | ? { afterTokens: compactedUsage.usedTokens } | |
| 3225 | : {}), | |
| 3223 | 3226 | detail: message, |
| 3224 | 3227 | }, |
| 3225 | 3228 | }); |
| 3226 | 3229 | return; |
| 3230 | } | |
| 3227 | 3231 | case "hook_started": |
| 3228 | 3232 | yield* offerRuntimeEvent({ |
| 3229 | 3233 | ...base, |
| ... | ... | @@ -3782,7 +3786,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3782 | 3786 | |
| 3783 | 3787 | // Same reason as the approvals above: a request nobody can answer any more |
| 3784 | 3788 | // must not stay open, or the thread can never be settled. |
| 3785 | for (const pending of [...context.pendingUserInputs.values()]) { | |
| 3789 | for (const pending of context.pendingUserInputs.values()) { | |
| 3786 | 3790 | yield* pending.cancel; |
| 3787 | 3791 | } |
| 3788 | 3792 | |
| ... | ... | @@ -4690,6 +4694,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4690 | 4694 | }; |
| 4691 | 4695 | }); |
| 4692 | 4696 | |
| 4697 | const compactThread: NonNullable<ClaudeAdapterShape["compactThread"]> = ( | |
| 4698 | threadId, | |
| 4699 | modelSelection, | |
| 4700 | ) => | |
| 4701 | sendTurn({ | |
| 4702 | threadId, | |
| 4703 | input: "/compact", | |
| 4704 | ...(modelSelection !== undefined ? { modelSelection } : {}), | |
| 4705 | }).pipe(Effect.asVoid); | |
| 4706 | ||
| 4693 | 4707 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 4694 | 4708 | function* (threadId, _turnId) { |
| 4695 | 4709 | const context = yield* requireSession(threadId); |
| ... | ... | @@ -4804,6 +4818,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4804 | 4818 | }, |
| 4805 | 4819 | startSession, |
| 4806 | 4820 | sendTurn, |
| 4821 | compactThread, | |
| 4807 | 4822 | interruptTurn, |
| 4808 | 4823 | readThread, |
| 4809 | 4824 | rollbackThread, |
apps/server/src/provider/Layers/ClaudeProvider.ts+2-7| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | |
| 23 | 23 | import { |
| 24 | 24 | buildServerProvider, |
| 25 | COMPACT_SLASH_COMMAND, | |
| 25 | 26 | DEFAULT_TIMEOUT_MS, |
| 26 | 27 | isCommandMissingCause, |
| 27 | 28 | parseGenericCliVersion, |
| ... | ... | @@ -503,13 +504,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 503 | 504 | ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) |
| 504 | 505 | : undefined; |
| 505 | 506 | const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); |
| 506 | const slashCommands = [ | |
| 507 | { | |
| 508 | name: "compact", | |
| 509 | description: "Summarize the conversation and reduce context usage", | |
| 510 | }, | |
| 511 | ...(capabilities?.slashCommands ?? []), | |
| 512 | ]; | |
| 507 | const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; | |
| 513 | 508 | const dedupedSlashCommands = dedupeSlashCommands(slashCommands); |
| 514 | 509 | |
| 515 | 510 | if (!capabilities) { |
apps/server/src/provider/Layers/CodexAdapter.test.ts+43| ... | ... | @@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { |
| 84 | 84 | }), |
| 85 | 85 | ); |
| 86 | 86 | |
| 87 | public readonly compactThread = Effect.void; | |
| 88 | ||
| 87 | 89 | public readonly interruptTurnImpl = vi.fn( |
| 88 | 90 | (_turnId?: TurnId): Promise<void> => Promise.resolve(undefined), |
| 89 | 91 | ); |
| ... | ... | @@ -336,6 +338,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 336 | 338 | }), |
| 337 | 339 | ); |
| 338 | 340 | |
| 341 | it.effect("compacts the active Codex thread and emits compacted state", () => | |
| 342 | Effect.gen(function* () { | |
| 343 | const adapter = yield* CodexAdapter; | |
| 344 | const threadId = asThreadId("thread-compact"); | |
| 345 | yield* adapter.startSession({ | |
| 346 | provider: ProviderDriverKind.make("codex"), | |
| 347 | threadId, | |
| 348 | runtimeMode: "full-access", | |
| 349 | }); | |
| 350 | const runtime = sessionRuntimeFactory.lastRuntime; | |
| 351 | NodeAssert.ok(runtime); | |
| 352 | const compactedEventFiber = yield* adapter.streamEvents.pipe( | |
| 353 | Stream.filter((event) => event.type === "thread.state.changed"), | |
| 354 | Stream.runHead, | |
| 355 | Effect.forkChild, | |
| 356 | ); | |
| 357 | yield* adapter.compactThread!(threadId); | |
| 358 | yield* runtime.emit({ | |
| 359 | id: asEventId("evt-compaction-item-completed"), | |
| 360 | kind: "notification", | |
| 361 | provider: ProviderDriverKind.make("codex"), | |
| 362 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 363 | method: "item/completed", | |
| 364 | threadId, | |
| 365 | payload: { | |
| 366 | completedAtMs: 1_778_000_000_000, | |
| 367 | threadId: "provider-thread-1", | |
| 368 | turnId: "provider-compact-turn", | |
| 369 | item: { | |
| 370 | id: "provider-compact-item", | |
| 371 | type: "contextCompaction", | |
| 372 | }, | |
| 373 | }, | |
| 374 | }); | |
| 375 | const event = Option.getOrThrow(yield* Fiber.join(compactedEventFiber)); | |
| 376 | NodeAssert.ok(event.type === "thread.state.changed"); | |
| 377 | NodeAssert.equal(event.payload.state, "compacted"); | |
| 378 | yield* adapter.stopSession(threadId); | |
| 379 | }), | |
| 380 | ); | |
| 381 | ||
| 339 | 382 | it.effect("uploads feedback for the active Codex thread", () => |
| 340 | 383 | Effect.gen(function* () { |
| 341 | 384 | const adapter = yield* CodexAdapter; |
apps/server/src/provider/Layers/CodexAdapter.ts+23-1| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | type CanonicalItemType, |
| 12 | 12 | type CanonicalRequestType, |
| 13 | 13 | type CodexSettings, |
| 14 | EventId, | |
| 14 | 15 | ProviderDriverKind, |
| 15 | 16 | type ProviderEvent, |
| 16 | 17 | ProviderInstanceId, |
| ... | ... | @@ -1151,7 +1152,18 @@ function mapToRuntimeEvents( |
| 1151 | 1152 | ]; |
| 1152 | 1153 | } |
| 1153 | 1154 | const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); |
| 1154 | return completed ? [completed] : []; | |
| 1155 | if (!completed || itemType !== "context_compaction") { | |
| 1156 | return completed ? [completed] : []; | |
| 1157 | } | |
| 1158 | return [ | |
| 1159 | completed, | |
| 1160 | { | |
| 1161 | ...runtimeEventBase(event, canonicalThreadId), | |
| 1162 | eventId: EventId.make(`${event.id}:thread-compacted`), | |
| 1163 | type: "thread.state.changed", | |
| 1164 | payload: { state: "compacted" }, | |
| 1165 | }, | |
| 1166 | ]; | |
| 1155 | 1167 | } |
| 1156 | 1168 | |
| 1157 | 1169 | if ( |
| ... | ... | @@ -1879,6 +1891,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1879 | 1891 | ), |
| 1880 | 1892 | ); |
| 1881 | 1893 | |
| 1894 | const compactThread: NonNullable<CodexAdapterShape["compactThread"]> = Effect.fn("compactThread")( | |
| 1895 | function* (threadId) { | |
| 1896 | const session = yield* requireSession(threadId); | |
| 1897 | yield* session.runtime.compactThread.pipe( | |
| 1898 | Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), | |
| 1899 | ); | |
| 1900 | }, | |
| 1901 | ); | |
| 1902 | ||
| 1882 | 1903 | const readThread: CodexAdapterShape["readThread"] = (threadId) => |
| 1883 | 1904 | requireSession(threadId).pipe( |
| 1884 | 1905 | Effect.flatMap((session) => session.runtime.readThread), |
| ... | ... | @@ -2014,6 +2035,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 2014 | 2035 | }, |
| 2015 | 2036 | startSession, |
| 2016 | 2037 | sendTurn, |
| 2038 | compactThread, | |
| 2017 | 2039 | interruptTurn, |
| 2018 | 2040 | readThread, |
| 2019 | 2041 | rollbackThread, |
apps/server/src/provider/Layers/CodexProvider.ts+2| ... | ... | @@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts |
| 30 | 30 | import { |
| 31 | 31 | AUTH_PROBE_TIMEOUT_MS, |
| 32 | 32 | buildServerProvider, |
| 33 | COMPACT_SLASH_COMMAND, | |
| 33 | 34 | type ServerProviderDraft, |
| 34 | 35 | } from "../providerSnapshot.ts"; |
| 35 | 36 | import { expandHomePath } from "../../pathExpansion.ts"; |
| ... | ... | @@ -648,6 +649,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 648 | 649 | models: snapshot.models, |
| 649 | 650 | skills: snapshot.skills, |
| 650 | 651 | slashCommands: [ |
| 652 | COMPACT_SLASH_COMMAND, | |
| 651 | 653 | { |
| 652 | 654 | name: "feedback", |
| 653 | 655 | description: "Send this thread and Codex logs to OpenAI", |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5| ... | ... | @@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape { |
| 196 | 196 | readonly sendTurn: ( |
| 197 | 197 | input: CodexSessionRuntimeSendTurnInput, |
| 198 | 198 | ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>; |
| 199 | readonly compactThread: Effect.Effect<void, CodexSessionRuntimeError>; | |
| 199 | 200 | readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>; |
| 200 | 201 | readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>; |
| 201 | 202 | readonly rollbackThread: ( |
| ... | ... | @@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = ( |
| 2293 | 2294 | return { |
| 2294 | 2295 | start, |
| 2295 | 2296 | getSession: Ref.get(sessionRef), |
| 2297 | compactThread: Effect.gen(function* () { | |
| 2298 | const providerThreadId = yield* readProviderThreadId; | |
| 2299 | yield* client.request("thread/compact/start", { threadId: providerThreadId }); | |
| 2300 | }), | |
| 2296 | 2301 | sendTurn: (input) => |
| 2297 | 2302 | Effect.gen(function* () { |
| 2298 | 2303 | const providerThreadId = yield* readProviderThreadId; |
apps/server/src/provider/Layers/CursorProvider.ts+2| ... | ... | @@ -34,6 +34,7 @@ import { |
| 34 | 34 | buildBooleanOptionDescriptor, |
| 35 | 35 | buildSelectOptionDescriptor, |
| 36 | 36 | buildServerProvider, |
| 37 | COMPACT_SLASH_COMMAND, | |
| 37 | 38 | collectStreamAsString, |
| 38 | 39 | isCommandMissingCause, |
| 39 | 40 | providerModelsFromSettings, |
| ... | ... | @@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: { |
| 639 | 640 | input.cursorSettings.customModels, |
| 640 | 641 | EMPTY_CAPABILITIES, |
| 641 | 642 | ), |
| 643 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 642 | 644 | probe: { |
| 643 | 645 | installed: true, |
| 644 | 646 | version: input.parsed.version, |
apps/server/src/provider/Layers/GrokProvider.ts+2| ... | ... | @@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 21 | 21 | import { |
| 22 | 22 | AUTH_PROBE_TIMEOUT_MS, |
| 23 | 23 | buildServerProvider, |
| 24 | COMPACT_SLASH_COMMAND, | |
| 24 | 25 | isCommandMissingCause, |
| 25 | 26 | parseGenericCliVersion, |
| 26 | 27 | providerModelsFromSettings, |
| ... | ... | @@ -496,6 +497,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 496 | 497 | checkedAt, |
| 497 | 498 | models, |
| 498 | 499 | skills, |
| 500 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 499 | 501 | probe: { |
| 500 | 502 | installed: true, |
| 501 | 503 | version, |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+39| ... | ... | @@ -79,6 +79,7 @@ const runtimeMock = { |
| 79 | 79 | messageCalls: [] as Array<{ sessionID: string; messageID: string }>, |
| 80 | 80 | messageFailures: 0, |
| 81 | 81 | promptCalls: [] as Array<unknown>, |
| 82 | summarizeCalls: [] as Array<unknown>, | |
| 82 | 83 | promptAsyncError: null as Error | null, |
| 83 | 84 | promptAsyncImplementation: null as (() => Promise<void>) | null, |
| 84 | 85 | autoPromptEcho: true, |
| ... | ... | @@ -129,6 +130,7 @@ const runtimeMock = { |
| 129 | 130 | this.state.messageCalls.length = 0; |
| 130 | 131 | this.state.messageFailures = 0; |
| 131 | 132 | this.state.promptCalls.length = 0; |
| 133 | this.state.summarizeCalls.length = 0; | |
| 132 | 134 | this.state.promptAsyncError = null; |
| 133 | 135 | this.state.promptAsyncImplementation = null; |
| 134 | 136 | this.state.autoPromptEcho = true; |
| ... | ... | @@ -313,6 +315,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 313 | 315 | }); |
| 314 | 316 | } |
| 315 | 317 | }, |
| 318 | summarize: async (input: unknown) => { | |
| 319 | runtimeMock.state.summarizeCalls.push(input); | |
| 320 | return { data: true }; | |
| 321 | }, | |
| 316 | 322 | messages: async () => ({ data: runtimeMock.state.messages }), |
| 317 | 323 | message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { |
| 318 | 324 | runtimeMock.state.messageCalls.push({ sessionID, messageID }); |
| ... | ... | @@ -949,6 +955,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 949 | 955 | }), |
| 950 | 956 | ); |
| 951 | 957 | |
| 958 | it.effect("compacts through the native OpenCode session API", () => | |
| 959 | Effect.gen(function* () { | |
| 960 | const adapter = yield* OpenCodeAdapter; | |
| 961 | const threadId = asThreadId("thread-opencode-compact"); | |
| 962 | runtimeMock.state.subscribedEvents.push({ | |
| 963 | type: "session.compacted", | |
| 964 | properties: { sessionID: "http://127.0.0.1:9999/session" }, | |
| 965 | }); | |
| 966 | const eventsFiber = yield* adapter.streamEvents.pipe( | |
| 967 | Stream.filter((event) => event.threadId === threadId), | |
| 968 | Stream.take(3), | |
| 969 | Stream.runCollect, | |
| 970 | Effect.forkChild, | |
| 971 | ); | |
| 972 | yield* adapter.startSession({ | |
| 973 | provider: ProviderDriverKind.make("opencode"), | |
| 974 | threadId, | |
| 975 | runtimeMode: "full-access", | |
| 976 | }); | |
| 977 | yield* adapter.compactThread!( | |
| 978 | threadId, | |
| 979 | createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), | |
| 980 | ); | |
| 981 | const summarizeCall = runtimeMock.state.summarizeCalls[0] as Record<string, unknown>; | |
| 982 | NodeAssert.equal(summarizeCall.modelID, "gpt-5"); | |
| 983 | const events = Array.from(yield* Fiber.join(eventsFiber)); | |
| 984 | yield* adapter.stopSession(threadId); | |
| 985 | const compacted = events.some( | |
| 986 | (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", | |
| 987 | ); | |
| 988 | NodeAssert.equal(compacted, true); | |
| 989 | }), | |
| 990 | ); | |
| 952 | 991 | it.effect("falls back to a fresh session when the persisted session is gone", () => |
| 953 | 992 | Effect.gen(function* () { |
| 954 | 993 | const adapter = yield* OpenCodeAdapter; |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+81| ... | ... | @@ -2014,6 +2014,21 @@ export function makeOpenCodeAdapter( |
| 2014 | 2014 | } |
| 2015 | 2015 | break; |
| 2016 | 2016 | } |
| 2017 | case "session.compacted": { | |
| 2018 | yield* emit({ | |
| 2019 | ...(yield* buildEventBase({ | |
| 2020 | threadId: context.session.threadId, | |
| 2021 | turnId, | |
| 2022 | raw: event, | |
| 2023 | })), | |
| 2024 | type: "thread.state.changed", | |
| 2025 | payload: { | |
| 2026 | state: "compacted", | |
| 2027 | detail: event, | |
| 2028 | }, | |
| 2029 | }); | |
| 2030 | break; | |
| 2031 | } | |
| 2017 | 2032 | |
| 2018 | 2033 | case "message.updated": { |
| 2019 | 2034 | const promptAdmission = context.promptAdmission; |
| ... | ... | @@ -2984,6 +2999,71 @@ export function makeOpenCodeAdapter( |
| 2984 | 2999 | ); |
| 2985 | 3000 | }); |
| 2986 | 3001 | |
| 3002 | const compactThread: NonNullable<OpenCodeAdapterShape["compactThread"]> = Effect.fn( | |
| 3003 | "compactThread", | |
| 3004 | )(function* (threadId, requestedModelSelection) { | |
| 3005 | const context = yield* ensureSessionContext(sessions, threadId); | |
| 3006 | yield* awaitOpenCodeContextReady(context); | |
| 3007 | const modelSelection = | |
| 3008 | requestedModelSelection ?? | |
| 3009 | (context.session.model | |
| 3010 | ? { instanceId: boundInstanceId, model: context.session.model } | |
| 3011 | : undefined); | |
| 3012 | if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { | |
| 3013 | return yield* new ProviderAdapterValidationError({ | |
| 3014 | provider: PROVIDER, | |
| 3015 | operation: "compactThread", | |
| 3016 | issue: `OpenCode model selection is bound to instance '${modelSelection.instanceId}', expected '${boundInstanceId}'.`, | |
| 3017 | }); | |
| 3018 | } | |
| 3019 | const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); | |
| 3020 | if (!parsedModel) { | |
| 3021 | return yield* new ProviderAdapterValidationError({ | |
| 3022 | provider: PROVIDER, | |
| 3023 | operation: "compactThread", | |
| 3024 | issue: "OpenCode compaction requires an active 'provider/model' selection.", | |
| 3025 | }); | |
| 3026 | } | |
| 3027 | yield* context.promptSemaphore.withPermit( | |
| 3028 | Effect.gen(function* () { | |
| 3029 | if (sessions.get(threadId) !== context || (yield* Ref.get(context.stopped))) { | |
| 3030 | return yield* Effect.interrupt; | |
| 3031 | } | |
| 3032 | if (context.activeTurnId !== undefined) { | |
| 3033 | return yield* new ProviderAdapterValidationError({ | |
| 3034 | provider: PROVIDER, | |
| 3035 | operation: "compactThread", | |
| 3036 | issue: "OpenCode cannot compact while a turn is running.", | |
| 3037 | }); | |
| 3038 | } | |
| 3039 | yield* runOpenCodeSdk("session.summarize", (signal) => | |
| 3040 | context.client.session.summarize( | |
| 3041 | { | |
| 3042 | sessionID: context.openCodeSessionId, | |
| 3043 | ...parsedModel, | |
| 3044 | auto: false, | |
| 3045 | }, | |
| 3046 | { signal }, | |
| 3047 | ), | |
| 3048 | ).pipe( | |
| 3049 | Effect.timeout("10 minutes"), | |
| 3050 | Effect.catchTags({ | |
| 3051 | OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), | |
| 3052 | TimeoutError: (cause) => | |
| 3053 | Effect.fail( | |
| 3054 | new ProviderAdapterRequestError({ | |
| 3055 | provider: PROVIDER, | |
| 3056 | method: "session.summarize", | |
| 3057 | detail: "OpenCode session compaction did not complete within 10 minutes.", | |
| 3058 | cause, | |
| 3059 | }), | |
| 3060 | ), | |
| 3061 | }), | |
| 3062 | Effect.asVoid, | |
| 3063 | ); | |
| 3064 | }), | |
| 3065 | ); | |
| 3066 | }); | |
| 2987 | 3067 | const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 2988 | 3068 | function* (threadId, turnId) { |
| 2989 | 3069 | const context = yield* ensureSessionContext(sessions, threadId); |
| ... | ... | @@ -3268,6 +3348,7 @@ export function makeOpenCodeAdapter( |
| 3268 | 3348 | }, |
| 3269 | 3349 | startSession, |
| 3270 | 3350 | sendTurn, |
| 3351 | compactThread, | |
| 3271 | 3352 | interruptTurn, |
| 3272 | 3353 | respondToRequest, |
| 3273 | 3354 | respondToUserInput, |
apps/server/src/provider/Layers/OpenCodeProvider.ts+2| ... | ... | @@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; |
| 13 | 13 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 14 | 14 | import { |
| 15 | 15 | buildServerProvider, |
| 16 | COMPACT_SLASH_COMMAND, | |
| 16 | 17 | nonEmptyTrimmed, |
| 17 | 18 | parseGenericCliVersion, |
| 18 | 19 | providerModelsFromSettings, |
| ... | ... | @@ -495,6 +496,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 495 | 496 | checkedAt, |
| 496 | 497 | models, |
| 497 | 498 | skills, |
| 499 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 498 | 500 | probe: { |
| 499 | 501 | installed: true, |
| 500 | 502 | version, |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+4-10| ... | ... | @@ -48,6 +48,7 @@ import { |
| 48 | 48 | import * as ServerConfig from "../../config.ts"; |
| 49 | 49 | import * as ServerSettingsModule from "../../serverSettings.ts"; |
| 50 | 50 | import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; |
| 51 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; | |
| 51 | 52 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 52 | 53 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; |
| 53 | 54 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; |
| ... | ... | @@ -384,7 +385,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 384 | 385 | shortDescription: "Debug failing GitHub Actions checks", |
| 385 | 386 | }, |
| 386 | 387 | ]); |
| 387 | assert.deepStrictEqual(status.slashCommands, [ | |
| 388 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | |
| 388 | 389 | { |
| 389 | 390 | name: "feedback", |
| 390 | 391 | description: "Send this thread and Codex logs to OpenAI", |
| ... | ... | @@ -2439,11 +2440,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 2439 | 2440 | }), |
| 2440 | 2441 | ); |
| 2441 | 2442 | |
| 2442 | assert.deepStrictEqual(status.slashCommands, [ | |
| 2443 | { | |
| 2444 | name: "compact", | |
| 2445 | description: "Summarize the conversation and reduce context usage", | |
| 2446 | }, | |
| 2443 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | |
| 2447 | 2444 | { |
| 2448 | 2445 | name: "review", |
| 2449 | 2446 | description: "Review a pull request", |
| ... | ... | @@ -2487,10 +2484,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 2487 | 2484 | ); |
| 2488 | 2485 | |
| 2489 | 2486 | assert.deepStrictEqual(status.slashCommands, [ |
| 2490 | { | |
| 2491 | name: "compact", | |
| 2492 | description: "Summarize the conversation and reduce context usage", | |
| 2493 | }, | |
| 2487 | COMPACT_SLASH_COMMAND, | |
| 2494 | 2488 | { |
| 2495 | 2489 | name: "ui", |
| 2496 | 2490 | description: "Explore and refine UI", |
apps/server/src/provider/Layers/ProviderService.test.ts+52| ... | ... | @@ -172,6 +172,18 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { |
| 172 | 172 | Effect.void, |
| 173 | 173 | ); |
| 174 | 174 | |
| 175 | const compactThread = vi.fn((threadId: ThreadId) => | |
| 176 | Effect.sync(() => | |
| 177 | emit({ | |
| 178 | type: "thread.state.changed", | |
| 179 | eventId: asEventId("evt-native-compact"), | |
| 180 | provider, | |
| 181 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 182 | threadId, | |
| 183 | payload: { state: "compacted" }, | |
| 184 | }), | |
| 185 | ), | |
| 186 | ); | |
| 175 | 187 | const respondToRequest = vi.fn( |
| 176 | 188 | ( |
| 177 | 189 | _threadId: ThreadId, |
| ... | ... | @@ -250,6 +262,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { |
| 250 | 262 | }, |
| 251 | 263 | startSession, |
| 252 | 264 | sendTurn, |
| 265 | ...(provider === CODEX_DRIVER ? { compactThread } : {}), | |
| 253 | 266 | interruptTurn, |
| 254 | 267 | respondToRequest, |
| 255 | 268 | respondToUserInput, |
| ... | ... | @@ -286,6 +299,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { |
| 286 | 299 | updateSession, |
| 287 | 300 | startSession, |
| 288 | 301 | sendTurn, |
| 302 | compactThread, | |
| 289 | 303 | interruptTurn, |
| 290 | 304 | respondToRequest, |
| 291 | 305 | respondToUserInput, |
| ... | ... | @@ -1034,6 +1048,10 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1034 | 1048 | }); |
| 1035 | 1049 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); |
| 1036 | 1050 | |
| 1051 | yield* advanceTestClock(10); | |
| 1052 | yield* provider.compactThread(session.threadId); | |
| 1053 | assert.deepEqual(routing.codex.compactThread.mock.calls, [[session.threadId, undefined]]); | |
| 1054 | ||
| 1037 | 1055 | yield* provider.interruptTurn({ threadId: session.threadId }); |
| 1038 | 1056 | assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]); |
| 1039 | 1057 | |
| ... | ... | @@ -1097,6 +1115,40 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1097 | 1115 | }), |
| 1098 | 1116 | ); |
| 1099 | 1117 | |
| 1118 | it.effect("marks a successful fallback compaction as compacted", () => | |
| 1119 | Effect.gen(function* () { | |
| 1120 | const provider = yield* ProviderService.ProviderService; | |
| 1121 | const threadId = asThreadId("thread-compact-cursor"); | |
| 1122 | yield* provider.startSession(threadId, { | |
| 1123 | provider: CURSOR_DRIVER, | |
| 1124 | providerInstanceId: ProviderInstanceId.make("cursor"), | |
| 1125 | threadId, | |
| 1126 | runtimeMode: "full-access", | |
| 1127 | }); | |
| 1128 | const compactedEventFiber = yield* provider.streamEvents.pipe( | |
| 1129 | Stream.filter((event) => event.type === "thread.state.changed"), | |
| 1130 | Stream.runHead, | |
| 1131 | Effect.forkChild, | |
| 1132 | ); | |
| 1133 | const compactFiber = yield* provider.compactThread(threadId).pipe(Effect.forkChild); | |
| 1134 | yield* advanceTestClock(50); | |
| 1135 | routing.cursor.emit({ | |
| 1136 | type: "turn.completed", | |
| 1137 | eventId: asEventId("evt-cursor-compact-completed"), | |
| 1138 | provider: CURSOR_DRIVER, | |
| 1139 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 1140 | threadId, | |
| 1141 | turnId: asTurnId(`turn-${threadId}`), | |
| 1142 | payload: { state: "completed" }, | |
| 1143 | }); | |
| 1144 | yield* Fiber.join(compactFiber); | |
| 1145 | ||
| 1146 | const compacted = yield* Fiber.join(compactedEventFiber); | |
| 1147 | assert.equal(compacted._tag, "Some"); | |
| 1148 | yield* provider.stopSession({ threadId }); | |
| 1149 | }), | |
| 1150 | ); | |
| 1151 | ||
| 1100 | 1152 | it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => |
| 1101 | 1153 | Effect.gen(function* () { |
| 1102 | 1154 | const provider = yield* ProviderService.ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+111-8| ... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 | * @module ProviderServiceLive |
| 11 | 11 | */ |
| 12 | 12 | import { |
| 13 | EventId, | |
| 13 | 14 | ModelSelection, |
| 14 | 15 | NonNegativeInt, |
| 15 | 16 | ThreadId, |
| ... | ... | @@ -28,6 +29,7 @@ import { |
| 28 | 29 | import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; |
| 29 | 30 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 30 | 31 | import * as DateTime from "effect/DateTime"; |
| 32 | import * as Deferred from "effect/Deferred"; | |
| 31 | 33 | import * as Effect from "effect/Effect"; |
| 32 | 34 | import * as Layer from "effect/Layer"; |
| 33 | 35 | import * as Option from "effect/Option"; |
| ... | ... | @@ -49,6 +51,7 @@ import { |
| 49 | 51 | providerTurnMetricAttributes, |
| 50 | 52 | withMetrics, |
| 51 | 53 | } from "../../observability/Metrics.ts"; |
| 54 | import { ProviderAdapterRequestError } from "../Errors.ts"; | |
| 52 | 55 | import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; |
| 53 | 56 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; |
| 54 | 57 | import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; |
| ... | ... | @@ -233,6 +236,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 233 | 236 | const revokeMcpCredential = |
| 234 | 237 | options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; |
| 235 | 238 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 239 | const pendingCompactions = new Map< | |
| 240 | ThreadId, | |
| 241 | { readonly completion: Deferred.Deferred<string>; readonly synthesizeCompactedEvent: boolean } | |
| 242 | >(); | |
| 243 | const settleCompaction = (threadId: ThreadId, terminal: string) => { | |
| 244 | const pending = pendingCompactions.get(threadId); | |
| 245 | pendingCompactions.delete(threadId); | |
| 246 | return pending | |
| 247 | ? Deferred.succeed(pending.completion, terminal).pipe(Effect.as(pending)) | |
| 248 | : Effect.succeed(undefined); | |
| 249 | }; | |
| 236 | 250 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 237 | 251 | /** |
| 238 | 252 | * Attach the `t3-code` MCP server to the session that is about to start. |
| ... | ... | @@ -345,14 +359,58 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 345 | 359 | }, |
| 346 | 360 | event: ProviderRuntimeEvent, |
| 347 | 361 | ): Effect.Effect<void> => |
| 348 | Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( | |
| 349 | Effect.flatMap((canonicalEvent) => | |
| 350 | increment(providerRuntimeEventsTotal, { | |
| 351 | provider: canonicalEvent.provider, | |
| 352 | eventType: canonicalEvent.type, | |
| 353 | }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), | |
| 354 | ), | |
| 355 | ); | |
| 362 | Effect.gen(function* () { | |
| 363 | const canonicalEvent = yield* Effect.sync(() => | |
| 364 | correlateRuntimeEventWithInstance(source, event), | |
| 365 | ); | |
| 366 | yield* increment(providerRuntimeEventsTotal, { | |
| 367 | provider: canonicalEvent.provider, | |
| 368 | eventType: canonicalEvent.type, | |
| 369 | }); | |
| 370 | yield* publishRuntimeEvent(canonicalEvent); | |
| 371 | const pendingCompaction = pendingCompactions.get(canonicalEvent.threadId); | |
| 372 | if (!pendingCompaction) return; | |
| 373 | if ( | |
| 374 | !pendingCompaction.synthesizeCompactedEvent && | |
| 375 | canonicalEvent.type === "thread.state.changed" && | |
| 376 | canonicalEvent.payload.state === "compacted" | |
| 377 | ) { | |
| 378 | yield* settleCompaction(canonicalEvent.threadId, "completed"); | |
| 379 | return; | |
| 380 | } | |
| 381 | const compactionTerminal = | |
| 382 | canonicalEvent.type === "turn.completed" | |
| 383 | ? canonicalEvent.payload.state | |
| 384 | : canonicalEvent.type === "session.exited" || | |
| 385 | canonicalEvent.type === "runtime.error" || | |
| 386 | canonicalEvent.type === "turn.aborted" | |
| 387 | ? canonicalEvent.type | |
| 388 | : null; | |
| 389 | const settledCompaction = | |
| 390 | compactionTerminal !== null && | |
| 391 | (yield* settleCompaction(canonicalEvent.threadId, compactionTerminal)); | |
| 392 | if ( | |
| 393 | !settledCompaction || | |
| 394 | compactionTerminal !== "completed" || | |
| 395 | !settledCompaction.synthesizeCompactedEvent | |
| 396 | ) { | |
| 397 | return; | |
| 398 | } | |
| 399 | const compactedEvent = { | |
| 400 | ...canonicalEvent, | |
| 401 | eventId: EventId.make(`${canonicalEvent.eventId}:context-compaction`), | |
| 402 | type: "thread.state.changed", | |
| 403 | payload: { | |
| 404 | state: "compacted", | |
| 405 | detail: { source: "provider-native-command" }, | |
| 406 | }, | |
| 407 | } satisfies ProviderRuntimeEvent; | |
| 408 | yield* increment(providerRuntimeEventsTotal, { | |
| 409 | provider: compactedEvent.provider, | |
| 410 | eventType: compactedEvent.type, | |
| 411 | }); | |
| 412 | yield* publishRuntimeEvent(compactedEvent); | |
| 413 | }); | |
| 356 | 414 | |
| 357 | 415 | // `subscribedAdapters` is our source-of-truth for "which instance adapters |
| 358 | 416 | // are currently wired into the runtime event bus". It both tracks the set |
| ... | ... | @@ -854,6 +912,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 854 | 912 | ); |
| 855 | 913 | }); |
| 856 | 914 | |
| 915 | const compactThread: ProviderServiceMethod<"compactThread"> = Effect.fn("compactThread")( | |
| 916 | function* (threadId, modelSelection) { | |
| 917 | const routed = yield* resolveRoutableSession({ | |
| 918 | threadId, | |
| 919 | operation: "ProviderService.compactThread", | |
| 920 | allowRecovery: true, | |
| 921 | }); | |
| 922 | yield* Effect.annotateCurrentSpan({ | |
| 923 | "provider.operation": "compact-thread", | |
| 924 | "provider.kind": routed.adapter.provider, | |
| 925 | "provider.thread_id": threadId, | |
| 926 | }); | |
| 927 | yield* McpSessionRegistry.touchActiveMcpThread(threadId); | |
| 928 | const nativeCompaction = routed.adapter.compactThread; | |
| 929 | const completion = yield* Deferred.make<string>(); | |
| 930 | pendingCompactions.set(threadId, { | |
| 931 | completion, | |
| 932 | synthesizeCompactedEvent: nativeCompaction === undefined, | |
| 933 | }); | |
| 934 | const terminal = yield* ( | |
| 935 | nativeCompaction | |
| 936 | ? nativeCompaction(routed.threadId, modelSelection) | |
| 937 | : sendTurn({ | |
| 938 | threadId, | |
| 939 | input: routed.adapter.provider === "cursor" ? "/compress" : "/compact", | |
| 940 | ...(modelSelection !== undefined ? { modelSelection } : {}), | |
| 941 | }) | |
| 942 | ).pipe( | |
| 943 | Effect.andThen(Deferred.await(completion)), | |
| 944 | Effect.ensuring(Effect.sync(() => void pendingCompactions.delete(threadId))), | |
| 945 | ); | |
| 946 | if (terminal !== "completed") { | |
| 947 | return yield* new ProviderAdapterRequestError({ | |
| 948 | provider: routed.adapter.provider, | |
| 949 | method: "turn/start", | |
| 950 | detail: `Context compaction ended with ${terminal}.`, | |
| 951 | }); | |
| 952 | } | |
| 953 | yield* analytics.record("provider.thread.compacted", { | |
| 954 | provider: routed.adapter.provider, | |
| 955 | }); | |
| 956 | }, | |
| 957 | ); | |
| 958 | ||
| 857 | 959 | const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")( |
| 858 | 960 | function* (rawInput) { |
| 859 | 961 | const input = yield* decodeInputOrValidationError({ |
| ... | ... | @@ -1249,6 +1351,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 1249 | 1351 | return { |
| 1250 | 1352 | startSession, |
| 1251 | 1353 | sendTurn, |
| 1354 | compactThread, | |
| 1252 | 1355 | interruptTurn, |
| 1253 | 1356 | respondToRequest, |
| 1254 | 1357 | respondToUserInput, |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | ... | @@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => { |
| 164 | 164 | const providerService: ProviderServiceShape = { |
| 165 | 165 | startSession: () => unsupported(), |
| 166 | 166 | sendTurn: () => unsupported(), |
| 167 | compactThread: () => unsupported(), | |
| 167 | 168 | interruptTurn: () => unsupported(), |
| 168 | 169 | respondToRequest: () => unsupported(), |
| 169 | 170 | respondToUserInput: () => unsupported(), |
apps/server/src/provider/Services/ProviderAdapter.ts+5| ... | ... | @@ -68,6 +68,11 @@ export interface ProviderAdapterShape<TError> { |
| 68 | 68 | input: ProviderSendTurnInput, |
| 69 | 69 | ) => Effect.Effect<ProviderTurnStartResult, TError>; |
| 70 | 70 | |
| 71 | readonly compactThread?: ( | |
| 72 | threadId: ThreadId, | |
| 73 | modelSelection?: ProviderSendTurnInput["modelSelection"], | |
| 74 | ) => Effect.Effect<void, TError>; | |
| 75 | ||
| 71 | 76 | /** |
| 72 | 77 | * Interrupt an active turn. |
| 73 | 78 | */ |
apps/server/src/provider/Services/ProviderService.ts+5| ... | ... | @@ -53,6 +53,11 @@ export interface ProviderServiceShape { |
| 53 | 53 | input: ProviderSendTurnInput, |
| 54 | 54 | ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>; |
| 55 | 55 | |
| 56 | readonly compactThread: ( | |
| 57 | threadId: ThreadId, | |
| 58 | modelSelection?: ProviderSendTurnInput["modelSelection"], | |
| 59 | ) => Effect.Effect<void, ProviderServiceError>; | |
| 60 | ||
| 56 | 61 | /** |
| 57 | 62 | * Interrupt a running provider turn. |
| 58 | 63 | */ |
apps/server/src/provider/providerSnapshot.ts+5| ... | ... | @@ -22,6 +22,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000; |
| 22 | 22 | // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) |
| 23 | 23 | export const AUTH_PROBE_TIMEOUT_MS = 10_000; |
| 24 | 24 | |
| 25 | export const COMPACT_SLASH_COMMAND = { | |
| 26 | name: "compact", | |
| 27 | description: "Summarize the conversation and reduce context usage", | |
| 28 | } satisfies ServerProviderSlashCommand; | |
| 29 | ||
| 25 | 30 | export interface CommandResult { |
| 26 | 31 | readonly stdout: string; |
| 27 | 32 | readonly stderr: string; |
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1| ... | ... | @@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) => |
| 51 | 51 | ({ |
| 52 | 52 | startSession: () => Effect.die("unused"), |
| 53 | 53 | sendTurn: () => Effect.die("unused"), |
| 54 | compactThread: () => Effect.die("unused"), | |
| 54 | 55 | interruptTurn: () => Effect.die("unused"), |
| 55 | 56 | respondToRequest: () => Effect.die("unused"), |
| 56 | 57 | respondToUserInput: () => Effect.die("unused"), |
apps/web/src/components/ChatView.tsx+21-19| ... | ... | @@ -328,7 +328,7 @@ import { |
| 328 | 328 | import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 329 | 329 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 330 | 330 | import { |
| 331 | hasAvailableClaudeCompactionProvider, | |
| 331 | hasAvailableCompactionProvider, | |
| 332 | 332 | hasDismissedResumeCompaction, |
| 333 | 333 | shouldOfferResumeCompaction, |
| 334 | 334 | } from "./chat/ContextWindowMeter.logic"; |
| ... | ... | @@ -2944,13 +2944,23 @@ function ChatViewContent(props: ChatViewProps) { |
| 2944 | 2944 | activeThread?.modelSelection.instanceId ?? |
| 2945 | 2945 | activeProject?.defaultModelSelection?.instanceId ?? |
| 2946 | 2946 | null; |
| 2947 | const compactionProviderAvailable = useMemo( | |
| 2947 | const activeProviderStatus = useMemo(() => { | |
| 2948 | if (activeProviderInstanceId) { | |
| 2949 | return ( | |
| 2950 | providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null | |
| 2951 | ); | |
| 2952 | } | |
| 2953 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); | |
| 2954 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; | |
| 2955 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); | |
| 2956 | const manualCompactionProviderAvailable = useMemo( | |
| 2948 | 2957 | () => |
| 2949 | hasAvailableClaudeCompactionProvider({ | |
| 2958 | hasAvailableCompactionProvider({ | |
| 2950 | 2959 | providers: applyProviderInstanceSettings( |
| 2951 | 2960 | deriveProviderInstanceEntries(providerStatuses), |
| 2952 | 2961 | settings, |
| 2953 | 2962 | ), |
| 2963 | driverKind: selectedProvider, | |
| 2954 | 2964 | instanceId: activeProviderInstanceId, |
| 2955 | 2965 | lockedInstanceId: lockedProvider |
| 2956 | 2966 | ? (activeThread?.session?.providerInstanceId ?? |
| ... | ... | @@ -2964,18 +2974,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 2964 | 2974 | activeThread?.session?.providerInstanceId, |
| 2965 | 2975 | lockedProvider, |
| 2966 | 2976 | providerStatuses, |
| 2977 | selectedProvider, | |
| 2967 | 2978 | settings, |
| 2968 | 2979 | ], |
| 2969 | 2980 | ); |
| 2970 | const activeProviderStatus = useMemo(() => { | |
| 2971 | if (activeProviderInstanceId) { | |
| 2972 | return ( | |
| 2973 | providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null | |
| 2974 | ); | |
| 2975 | } | |
| 2976 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); | |
| 2977 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; | |
| 2978 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); | |
| 2979 | 2981 | const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = |
| 2980 | 2982 | useLocalStorage( |
| 2981 | 2983 | `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, |
| ... | ... | @@ -5123,12 +5125,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 5123 | 5125 | activeThread && activeContextWindow |
| 5124 | 5126 | ? `${activeThread.id}:${activeContextWindow.updatedAt}` |
| 5125 | 5127 | : null; |
| 5126 | const compactDisabled = | |
| 5128 | const compactThreadUnavailable = | |
| 5127 | 5129 | !activeThread || |
| 5128 | 5130 | !activeProject || |
| 5129 | 5131 | !isServerThread || |
| 5130 | selectedProvider !== "claudeAgent" || | |
| 5131 | !compactionProviderAvailable || | |
| 5132 | !manualCompactionProviderAvailable || | |
| 5132 | 5133 | isWorking || |
| 5133 | 5134 | threadDetailLoading || |
| 5134 | 5135 | isPreparingWorktree || |
| ... | ... | @@ -5136,15 +5137,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 5136 | 5137 | feedbackUploading || |
| 5137 | 5138 | pendingApprovals.length > 0 || |
| 5138 | 5139 | pendingUserInputs.length > 0 || |
| 5139 | showPlanFollowUpPrompt || | |
| 5140 | composerHasUnsentContent; | |
| 5140 | showPlanFollowUpPrompt; | |
| 5141 | const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; | |
| 5141 | 5142 | const compactDisabledReason = compactDisabled |
| 5142 | 5143 | ? composerHasUnsentContent |
| 5143 | 5144 | ? "Send or clear your draft before compacting" |
| 5144 | 5145 | : !activeProject |
| 5145 | 5146 | ? "Choose a project before compacting" |
| 5146 | : !compactionProviderAvailable | |
| 5147 | ? "Enable a Claude provider before compacting" | |
| 5147 | : !manualCompactionProviderAvailable | |
| 5148 | ? "Compaction is unavailable for this provider" | |
| 5148 | 5149 | : "Compacting is unavailable right now" |
| 5149 | 5150 | : null; |
| 5150 | 5151 | const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| ... | ... | @@ -7528,6 +7529,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7528 | 7529 | } |
| 7529 | 7530 | activeThreadModelSelection={activeThread?.modelSelection} |
| 7530 | 7531 | activeContextWindow={activeContextWindow} |
| 7532 | compactThreadUnavailable={compactThreadUnavailable} | |
| 7531 | 7533 | compactDisabled={compactDisabled} |
| 7532 | 7534 | compactDisabledReason={compactDisabledReason} |
| 7533 | 7535 | resolvedTheme={resolvedTheme} |
apps/web/src/components/chat/ChatComposer.tsx+23-8| ... | ... | @@ -163,7 +163,10 @@ import { |
| 163 | 163 | renderProviderTraitsPicker, |
| 164 | 164 | } from "./composerProviderState"; |
| 165 | 165 | import { ContextWindowMeter } from "./ContextWindowMeter"; |
| 166 | import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; | |
| 166 | import { | |
| 167 | providerSupportsManualCompaction, | |
| 168 | resolveContextWindowModelDisplayName, | |
| 169 | } from "./ContextWindowMeter.logic"; | |
| 167 | 170 | import { |
| 168 | 171 | attachVideoThumbnail, |
| 169 | 172 | buildExpandedImagePreview, |
| ... | ... | @@ -691,6 +694,7 @@ export interface ChatComposerProps { |
| 691 | 694 | |
| 692 | 695 | // Context window |
| 693 | 696 | activeContextWindow: ContextWindowSnapshot | null; |
| 697 | compactThreadUnavailable: boolean; | |
| 694 | 698 | compactDisabled: boolean; |
| 695 | 699 | compactDisabledReason: string | null; |
| 696 | 700 | |
| ... | ... | @@ -787,6 +791,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 787 | 791 | activeProjectDefaultModelSelection, |
| 788 | 792 | activeThreadModelSelection, |
| 789 | 793 | activeContextWindow, |
| 794 | compactThreadUnavailable, | |
| 790 | 795 | compactDisabled, |
| 791 | 796 | compactDisabledReason, |
| 792 | 797 | resolvedTheme, |
| ... | ... | @@ -1120,6 +1125,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1120 | 1125 | () => selectedProviderEntry?.snapshot ?? null, |
| 1121 | 1126 | [selectedProviderEntry], |
| 1122 | 1127 | ); |
| 1128 | const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry); | |
| 1123 | 1129 | const selectedProviderSkills = selectedProviderStatus |
| 1124 | 1130 | ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) |
| 1125 | 1131 | : []; |
| ... | ... | @@ -1348,7 +1354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1348 | 1354 | prompt, |
| 1349 | 1355 | ], |
| 1350 | 1356 | ); |
| 1351 | ||
| 1352 | 1357 | // ------------------------------------------------------------------ |
| 1353 | 1358 | // Derived: composer trigger / menu |
| 1354 | 1359 | // ------------------------------------------------------------------ |
| ... | ... | @@ -1360,6 +1365,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1360 | 1365 | cwd: isPathTrigger ? gitCwd : null, |
| 1361 | 1366 | query: isPathTrigger ? pathTriggerQuery : null, |
| 1362 | 1367 | }); |
| 1368 | const compactSlashCommandAvailable = | |
| 1369 | composerTrigger?.kind === "slash-command" && | |
| 1370 | !compactThreadUnavailable && | |
| 1371 | prompt.slice(composerTrigger.rangeEnd).trim() === "" && | |
| 1372 | composerImages.length + composerFiles.length === 0 && | |
| 1373 | composerDraft.persistedAttachments.length === 0 && | |
| 1374 | composerTerminalContexts.length === 0 && | |
| 1375 | composerElementContexts.length === 0 && | |
| 1376 | composerPreviewAnnotations.length === 0 && | |
| 1377 | composerReviewComments.length === 0; | |
| 1363 | 1378 | |
| 1364 | 1379 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 1365 | 1380 | if (!composerTrigger) return []; |
| ... | ... | @@ -1428,8 +1443,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1428 | 1443 | skill.description ?? |
| 1429 | 1444 | (skill.scope ? `${skill.scope} skill` : ""), |
| 1430 | 1445 | })); |
| 1446 | const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( | |
| 1447 | (item) => item.command.name !== "compact" || compactSlashCommandAvailable, | |
| 1448 | ); | |
| 1431 | 1449 | const slashCommandItems = slashCommandItemsForPromptPosition( |
| 1432 | [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems], | |
| 1450 | [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], | |
| 1433 | 1451 | composerTrigger.rangeStart === 0, |
| 1434 | 1452 | ); |
| 1435 | 1453 | return searchSlashCommandItems(slashCommandItems, query); |
| ... | ... | @@ -1449,6 +1467,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1449 | 1467 | } |
| 1450 | 1468 | return []; |
| 1451 | 1469 | }, [ |
| 1470 | compactSlashCommandAvailable, | |
| 1452 | 1471 | composerTrigger, |
| 1453 | 1472 | planModeUiEnabled, |
| 1454 | 1473 | selectedProvider, |
| ... | ... | @@ -2298,7 +2317,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2298 | 2317 | if ( |
| 2299 | 2318 | compactDisabled || |
| 2300 | 2319 | noProviderAvailable || |
| 2301 | composerSendState.hasSendableContent || | |
| 2302 | 2320 | activePendingApproval !== null || |
| 2303 | 2321 | pendingUserInputs.length > 0 || |
| 2304 | 2322 | phase === "running" || |
| ... | ... | @@ -2336,7 +2354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2336 | 2354 | activeThreadId, |
| 2337 | 2355 | compactDisabled, |
| 2338 | 2356 | composerDraftTarget, |
| 2339 | composerSendState.hasSendableContent, | |
| 2340 | 2357 | isConnecting, |
| 2341 | 2358 | isSendBusy, |
| 2342 | 2359 | noProviderAvailable, |
| ... | ... | @@ -4402,9 +4419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4402 | 4419 | compactDisabled || noProviderAvailable || isSendBusy || isConnecting |
| 4403 | 4420 | } |
| 4404 | 4421 | compactDisabledReason={resolvedCompactDisabledReason} |
| 4405 | {...(selectedProvider === "claudeAgent" | |
| 4406 | ? { onCompactContext: compactThreadContext } | |
| 4407 | : {})} | |
| 4422 | {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} | |
| 4408 | 4423 | /> |
| 4409 | 4424 | </div> |
| 4410 | 4425 | </div> |
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+7-5| ... | ... | @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { deriveProviderInstanceEntries } from "../../providerInstances"; |
| 4 | 4 | import { |
| 5 | 5 | formatContextWindowCompactionMessage, |
| 6 | hasAvailableClaudeCompactionProvider, | |
| 6 | hasAvailableCompactionProvider, | |
| 7 | 7 | hasDismissedResumeCompaction, |
| 8 | 8 | resolveContextWindowModelDisplayName, |
| 9 | 9 | shouldOfferResumeCompaction, |
| ... | ... | @@ -25,12 +25,12 @@ function claudeProvider(input: { |
| 25 | 25 | auth: { status: "authenticated" }, |
| 26 | 26 | checkedAt: "2026-08-24T12:00:00.000Z", |
| 27 | 27 | models: [], |
| 28 | slashCommands: [], | |
| 28 | slashCommands: [{ name: "compact", description: "" }], | |
| 29 | 29 | skills: [], |
| 30 | 30 | }; |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | describe("hasAvailableClaudeCompactionProvider", () => { | |
| 33 | describe("hasAvailableCompactionProvider", () => { | |
| 34 | 34 | const originalInstanceId = ProviderInstanceId.make("claude_original"); |
| 35 | 35 | |
| 36 | 36 | it("rejects a fallback in a different locked continuation group", () => { |
| ... | ... | @@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => { |
| 47 | 47 | ]); |
| 48 | 48 | |
| 49 | 49 | expect( |
| 50 | hasAvailableClaudeCompactionProvider({ | |
| 50 | hasAvailableCompactionProvider({ | |
| 51 | 51 | providers, |
| 52 | driverKind: ProviderDriverKind.make("claudeAgent"), | |
| 52 | 53 | instanceId: originalInstanceId, |
| 53 | 54 | lockedInstanceId: originalInstanceId, |
| 54 | 55 | }), |
| ... | ... | @@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => { |
| 69 | 70 | ]); |
| 70 | 71 | |
| 71 | 72 | expect( |
| 72 | hasAvailableClaudeCompactionProvider({ | |
| 73 | hasAvailableCompactionProvider({ | |
| 73 | 74 | providers, |
| 75 | driverKind: ProviderDriverKind.make("claudeAgent"), | |
| 74 | 76 | instanceId: originalInstanceId, |
| 75 | 77 | lockedInstanceId: originalInstanceId, |
| 76 | 78 | }), |
apps/web/src/components/chat/ContextWindowMeter.logic.ts+16-10| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; | |
| 1 | import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, |
| 4 | 4 | isClaudeResumeCompactionQuestion, |
| ... | ... | @@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils |
| 12 | 12 | export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; |
| 13 | 13 | export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; |
| 14 | 14 | |
| 15 | export function hasAvailableClaudeCompactionProvider(input: { | |
| 15 | export function providerSupportsManualCompaction( | |
| 16 | provider: ProviderInstanceEntry | null | undefined, | |
| 17 | ): boolean { | |
| 18 | return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false; | |
| 19 | } | |
| 20 | ||
| 21 | export function hasAvailableCompactionProvider(input: { | |
| 16 | 22 | readonly providers: ReadonlyArray<ProviderInstanceEntry>; |
| 23 | readonly driverKind: ProviderDriverKind; | |
| 17 | 24 | readonly instanceId: ProviderInstanceId | null; |
| 18 | 25 | readonly lockedInstanceId: ProviderInstanceId | null; |
| 19 | 26 | }): boolean { |
| 20 | const claudeProviders = input.providers.filter( | |
| 21 | (provider) => provider.driverKind === "claudeAgent", | |
| 27 | const driverProviders = input.providers.filter( | |
| 28 | (provider) => provider.driverKind === input.driverKind, | |
| 22 | 29 | ); |
| 23 | 30 | const lockedContinuationGroupKey = input.lockedInstanceId |
| 24 | ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId) | |
| 31 | ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId) | |
| 25 | 32 | ?.continuationGroupKey |
| 26 | 33 | : undefined; |
| 27 | 34 | const compatibleProviders = lockedContinuationGroupKey |
| 28 | ? claudeProviders.filter( | |
| 35 | ? driverProviders.filter( | |
| 29 | 36 | (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, |
| 30 | 37 | ) |
| 31 | : claudeProviders; | |
| 38 | : driverProviders; | |
| 32 | 39 | |
| 33 | return ( | |
| 34 | resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !== | |
| 35 | undefined | |
| 40 | return providerSupportsManualCompaction( | |
| 41 | resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined), | |
| 36 | 42 | ); |
| 37 | 43 | } |
| 38 | 44 |
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-2| ... | ... | @@ -919,7 +919,7 @@ describe("MessagesTimeline", () => { |
| 919 | 919 | entry: { |
| 920 | 920 | id: "work-1", |
| 921 | 921 | createdAt: "2026-03-17T19:12:28.000Z", |
| 922 | label: "Context compacted", | |
| 922 | label: "Compacted context 899K → 19K tokens", | |
| 923 | 923 | tone: "info", |
| 924 | 924 | }, |
| 925 | 925 | }, |
| ... | ... | @@ -927,7 +927,7 @@ describe("MessagesTimeline", () => { |
| 927 | 927 | />, |
| 928 | 928 | ); |
| 929 | 929 | |
| 930 | expect(markup).toContain("Context compacted"); | |
| 930 | expect(markup).toContain("Compacted context 899K → 19K tokens"); | |
| 931 | 931 | }); |
| 932 | 932 | |
| 933 | 933 | it("summarizes changed files in one line", () => { |
docs/user/composer.md+2| ... | ... | @@ -196,6 +196,8 @@ such as System, Personal, Project, or App. |
| 196 | 196 | On mobile, these menus are available on the **New task** screen before you start a thread. They |
| 197 | 197 | use the skills and commands from the selected environment and provider. |
| 198 | 198 | |
| 199 | In an existing thread, send `/compact` to reduce context usage. Web and desktop also offer this action from the context meter, and the work log records token counts when the provider reports them. | |
| 200 | ||
| 199 | 201 | By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in |
| 200 | 202 | slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the |
| 201 | 203 | same `$name` skill token to your message. The original skill name remains searchable. If the provider |
packages/contracts/src/providerRuntime.ts+2| ... | ... | @@ -298,6 +298,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; |
| 298 | 298 | |
| 299 | 299 | const ThreadStateChangedPayload = Schema.Struct({ |
| 300 | 300 | state: RuntimeThreadState, |
| 301 | beforeTokens: Schema.optional(NonNegativeInt), | |
| 302 | afterTokens: Schema.optional(NonNegativeInt), | |
| 301 | 303 | detail: Schema.optional(Schema.Unknown), |
| 302 | 304 | }); |
| 303 | 305 | export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; |