| author | |
| committer | |
| log | 63f334baf3432c4404ddfc9e71d33bf16575f0ed |
| tree | a868ddf8cc912c2ae8921964e29f219103884f95 |
| parent | 064392ffc7fc58fcc3018fd2ba2df33fbb21f1de |
| signature | Signed by PGP key B5690EEEBB952194 |
35 files changed, 114 insertions(+), 646 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 | (command.name === "compact" || | |
| 131 | (selectedProviderStatus?.driver === "codex" && command.name === "feedback")) | |
| 130 | selectedProviderStatus?.driver === "codex" && | |
| 131 | command.name === "feedback" | |
| 132 | 132 | ) { |
| 133 | 133 | continue; |
| 134 | 134 | } |
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts-1| ... | ... | @@ -110,7 +110,6 @@ 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"), | |
| 114 | 113 | interruptTurn: () => Effect.die("unused"), |
| 115 | 114 | respondToRequest: () => Effect.die("unused"), |
| 116 | 115 | respondToUserInput: () => Effect.die("unused"), |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts-1| ... | ... | @@ -107,7 +107,6 @@ function createProviderServiceHarness( |
| 107 | 107 | const service: ProviderServiceShape = { |
| 108 | 108 | startSession: () => unsupported(), |
| 109 | 109 | sendTurn: () => unsupported(), |
| 110 | compactThread: () => unsupported(), | |
| 111 | 110 | interruptTurn: () => unsupported(), |
| 112 | 111 | respondToRequest: () => unsupported(), |
| 113 | 112 | respondToUserInput: () => unsupported(), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts-15| ... | ... | @@ -1275,21 +1275,6 @@ 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 | ||
| 1293 | 1278 | case "thread.session-set": { |
| 1294 | 1279 | const turnId = event.payload.session.activeTurnId; |
| 1295 | 1280 | if (turnId === null || event.payload.session.status !== "running") { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts-24| ... | ... | @@ -254,7 +254,6 @@ describe("ProviderCommandReactor", () => { |
| 254 | 254 | turnId: asTurnId("turn-1"), |
| 255 | 255 | }), |
| 256 | 256 | ); |
| 257 | const compactThread = vi.fn((_: ThreadId) => Effect.void); | |
| 258 | 257 | const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); |
| 259 | 258 | const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void); |
| 260 | 259 | const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void); |
| ... | ... | @@ -340,7 +339,6 @@ describe("ProviderCommandReactor", () => { |
| 340 | 339 | const service: ProviderServiceShape = { |
| 341 | 340 | startSession: startSession as ProviderServiceShape["startSession"], |
| 342 | 341 | sendTurn: sendTurn as ProviderServiceShape["sendTurn"], |
| 343 | compactThread, | |
| 344 | 342 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], |
| 345 | 343 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], |
| 346 | 344 | respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], |
| ... | ... | @@ -535,7 +533,6 @@ describe("ProviderCommandReactor", () => { |
| 535 | 533 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), |
| 536 | 534 | startSession, |
| 537 | 535 | sendTurn, |
| 538 | compactThread, | |
| 539 | 536 | interruptTurn, |
| 540 | 537 | respondToRequest, |
| 541 | 538 | respondToUserInput, |
| ... | ... | @@ -635,27 +632,6 @@ describe("ProviderCommandReactor", () => { |
| 635 | 632 | }), |
| 636 | 633 | ); |
| 637 | 634 | |
| 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 | ); | |
| 659 | 635 | effectIt.effect("projects starting before a slow provider session finishes", () => |
| 660 | 636 | Effect.gen(function* () { |
| 661 | 637 | const releaseStart = yield* Deferred.make<void>(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+21-96| ... | ... | @@ -30,10 +30,7 @@ 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 { | |
| 34 | ProviderAdapterRequestError, | |
| 35 | ProviderAdapterValidationError, | |
| 36 | } from "../../provider/Errors.ts"; | |
| 33 | import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; | |
| 37 | 34 | import type { ProviderServiceError } from "../../provider/Errors.ts"; |
| 38 | 35 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 39 | 36 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| ... | ... | @@ -53,7 +50,6 @@ import { |
| 53 | 50 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 54 | 51 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 55 | 52 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); |
| 56 | const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); | |
| 57 | 53 | const isProviderDriverKind = Schema.is(ProviderDriverKind); |
| 58 | 54 | |
| 59 | 55 | type ProviderIntentEvent = Extract< |
| ... | ... | @@ -76,10 +72,6 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined |
| 76 | 72 | return normalized && normalized.length > 0 ? normalized : undefined; |
| 77 | 73 | } |
| 78 | 74 | |
| 79 | const isCompactCommandMessage = (message: ThreadTitleMessage): boolean => | |
| 80 | message.role === "user" && | |
| 81 | (message.attachments?.length ?? 0) === 0 && | |
| 82 | message.text.trim().toLowerCase() === "/compact"; | |
| 83 | 75 | function mapProviderSessionStatusToOrchestrationStatus( |
| 84 | 76 | status: "connecting" | "ready" | "running" | "error" | "closed", |
| 85 | 77 | ): OrchestrationSession["status"] { |
| ... | ... | @@ -340,7 +332,6 @@ const make = Effect.gen(function* () { |
| 340 | 332 | ); |
| 341 | 333 | |
| 342 | 334 | const threadModelSelections = new Map<string, ModelSelection>(); |
| 343 | const compactingThreadIds = new Set<ThreadId>(); | |
| 344 | 335 | |
| 345 | 336 | const appendProviderFailureActivity = (input: { |
| 346 | 337 | readonly threadId: ThreadId; |
| ... | ... | @@ -384,11 +375,11 @@ const make = Effect.gen(function* () { |
| 384 | 375 | |
| 385 | 376 | const formatFailureDetail = (cause: Cause.Cause<unknown>): string => { |
| 386 | 377 | const failReason = cause.reasons.find(Cause.isFailReason); |
| 387 | if (isProviderAdapterRequestError(failReason?.error)) { | |
| 388 | return failReason.error.detail; | |
| 389 | } | |
| 390 | if (isProviderAdapterValidationError(failReason?.error)) { | |
| 391 | return failReason.error.issue; | |
| 378 | const providerError = isProviderAdapterRequestError(failReason?.error) | |
| 379 | ? failReason.error | |
| 380 | : undefined; | |
| 381 | if (providerError) { | |
| 382 | return providerError.detail; | |
| 392 | 383 | } |
| 393 | 384 | return Cause.pretty(cause); |
| 394 | 385 | }; |
| ... | ... | @@ -1146,6 +1137,7 @@ const make = Effect.gen(function* () { |
| 1146 | 1137 | if (!thread) { |
| 1147 | 1138 | return; |
| 1148 | 1139 | } |
| 1140 | ||
| 1149 | 1141 | const message = thread.messages.find((entry) => entry.id === event.payload.messageId); |
| 1150 | 1142 | if (!message || message.role !== "user") { |
| 1151 | 1143 | yield* appendProviderFailureActivity({ |
| ... | ... | @@ -1155,28 +1147,15 @@ const make = Effect.gen(function* () { |
| 1155 | 1147 | detail: `User message '${event.payload.messageId}' was not found for turn start request.`, |
| 1156 | 1148 | turnId: null, |
| 1157 | 1149 | createdAt: event.payload.createdAt, |
| 1158 | requestId: event.payload.messageId, | |
| 1159 | 1150 | }); |
| 1160 | 1151 | return; |
| 1161 | 1152 | } |
| 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 | }); | |
| 1172 | 1153 | |
| 1173 | 1154 | yield* ensureThreadWorktree(thread); |
| 1174 | 1155 | |
| 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) { | |
| 1156 | const isFirstUserMessageTurn = | |
| 1157 | thread.messages.filter((entry) => entry.role === "user").length === 1; | |
| 1158 | if (isFirstUserMessageTurn) { | |
| 1180 | 1159 | const project = yield* resolveProject(thread.projectId); |
| 1181 | 1160 | const generationCwd = |
| 1182 | 1161 | resolveThreadWorkspaceCwd({ |
| ... | ... | @@ -1215,7 +1194,16 @@ const make = Effect.gen(function* () { |
| 1215 | 1194 | detail, |
| 1216 | 1195 | createdAt: event.payload.createdAt, |
| 1217 | 1196 | }).pipe( |
| 1218 | Effect.flatMap(() => appendTurnStartFailure("Provider turn start failed", detail)), | |
| 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 | ), | |
| 1219 | 1207 | Effect.asVoid, |
| 1220 | 1208 | ); |
| 1221 | 1209 | }; |
| ... | ... | @@ -1232,69 +1220,6 @@ const make = Effect.gen(function* () { |
| 1232 | 1220 | ), |
| 1233 | 1221 | ); |
| 1234 | 1222 | |
| 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 | } | |
| 1298 | 1223 | const sendTurnRequest = yield* buildSendTurnRequestForThread({ |
| 1299 | 1224 | threadId: event.payload.threadId, |
| 1300 | 1225 | messageText: message.text, |
| ... | ... | @@ -1315,7 +1240,7 @@ const make = Effect.gen(function* () { |
| 1315 | 1240 | |
| 1316 | 1241 | yield* providerService |
| 1317 | 1242 | .sendTurn(sendTurnRequest.value) |
| 1318 | .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); | |
| 1243 | .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); | |
| 1319 | 1244 | }); |
| 1320 | 1245 | |
| 1321 | 1246 | const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1-4| ... | ... | @@ -105,7 +105,6 @@ function createProviderServiceHarness() { |
| 105 | 105 | const service: ProviderServiceShape = { |
| 106 | 106 | startSession: () => unsupported(), |
| 107 | 107 | sendTurn: () => unsupported(), |
| 108 | compactThread: () => unsupported(), | |
| 109 | 108 | interruptTurn: () => unsupported(), |
| 110 | 109 | respondToRequest: () => unsupported(), |
| 111 | 110 | respondToUserInput: () => unsupported(), |
| ... | ... | @@ -3222,8 +3221,6 @@ describe("ProviderRuntimeIngestion", () => { |
| 3222 | 3221 | turnId: asTurnId("turn-1"), |
| 3223 | 3222 | payload: { |
| 3224 | 3223 | state: "compacted", |
| 3225 | beforeTokens: 899_000, | |
| 3226 | afterTokens: 19_000, | |
| 3227 | 3224 | detail: { source: "provider" }, |
| 3228 | 3225 | }, |
| 3229 | 3226 | }); |
| ... | ... | @@ -3237,7 +3234,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 3237 | 3234 | const activity = thread.activities.find( |
| 3238 | 3235 | (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction", |
| 3239 | 3236 | ); |
| 3240 | expect(activity?.summary).toBe("Compacted context 899K → 19K tokens"); | |
| 3237 | expect(activity?.summary).toBe("Context compacted"); | |
| 3241 | 3238 | expect(activity?.tone).toBe("info"); |
| 3242 | 3239 | }); |
| 3243 | 3240 |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1-10| ... | ... | @@ -28,7 +28,6 @@ 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"; | |
| 32 | 31 | |
| 33 | 32 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 34 | 33 | import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; |
| ... | ... | @@ -754,23 +753,15 @@ export function runtimeEventToActivities( |
| 754 | 753 | return []; |
| 755 | 754 | } |
| 756 | 755 | |
| 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"; | |
| 763 | 756 | return [ |
| 764 | 757 | { |
| 765 | 758 | id: event.eventId, |
| 766 | 759 | createdAt: event.createdAt, |
| 767 | 760 | tone: "info", |
| 768 | 761 | kind: "context-compaction", |
| 769 | summary, | |
| 762 | summary: "Context compacted", | |
| 770 | 763 | payload: { |
| 771 | 764 | state: event.payload.state, |
| 772 | ...(beforeTokens !== undefined ? { beforeTokens } : {}), | |
| 773 | ...(afterTokens !== undefined ? { afterTokens } : {}), | |
| 774 | 765 | ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), |
| 775 | 766 | }, |
| 776 | 767 | turnId: toTurnId(event.turnId) ?? null, |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+6-7| ... | ... | @@ -729,7 +729,12 @@ describe("ClaudeAdapterLive", () => { |
| 729 | 729 | runtimeMode: "full-access", |
| 730 | 730 | }); |
| 731 | 731 | |
| 732 | yield* adapter.compactThread!(session.threadId, modelSelection); | |
| 732 | yield* adapter.sendTurn({ | |
| 733 | threadId: session.threadId, | |
| 734 | input: "/compact", | |
| 735 | attachments: [], | |
| 736 | modelSelection, | |
| 737 | }); | |
| 733 | 738 | |
| 734 | 739 | const promptText = yield* Effect.promise(() => |
| 735 | 740 | readFirstPromptText(harness.getLastCreateQueryInput()), |
| ... | ... | @@ -2227,12 +2232,6 @@ describe("ClaudeAdapterLive", () => { |
| 2227 | 2232 | } as unknown as SDKMessage); |
| 2228 | 2233 | |
| 2229 | 2234 | 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); | |
| 2236 | 2235 | const finalUsageEvent = runtimeEvents.findLast( |
| 2237 | 2236 | (event) => event.type === "thread.token-usage.updated", |
| 2238 | 2237 | ); |
apps/server/src/provider/Layers/ClaudeAdapter.ts+13-28| ... | ... | @@ -3198,36 +3198,32 @@ 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 | const compactedUsage = compactBoundaryTokenUsageSnapshot( | |
| 3207 | message as unknown as Record<string, unknown>, | |
| 3208 | context.lastKnownContextWindow, | |
| 3209 | context.lastKnownTotalProcessedTokens, | |
| 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 | }, | |
| 3210 | 3217 | ); |
| 3211 | yield* emitThreadTokenUsage(context, compactedUsage, { | |
| 3212 | rawMethod: "claude/system/compact_boundary", | |
| 3213 | rawPayload: message, | |
| 3214 | }); | |
| 3215 | 3218 | yield* offerRuntimeEvent({ |
| 3216 | 3219 | ...base, |
| 3217 | 3220 | type: "thread.state.changed", |
| 3218 | 3221 | payload: { |
| 3219 | 3222 | state: "compacted", |
| 3220 | ...(compactedUsage?.lastUsedTokens !== undefined | |
| 3221 | ? { beforeTokens: compactedUsage.lastUsedTokens } | |
| 3222 | : {}), | |
| 3223 | ...(compactedUsage?.usedTokens !== undefined | |
| 3224 | ? { afterTokens: compactedUsage.usedTokens } | |
| 3225 | : {}), | |
| 3226 | 3223 | detail: message, |
| 3227 | 3224 | }, |
| 3228 | 3225 | }); |
| 3229 | 3226 | return; |
| 3230 | } | |
| 3231 | 3227 | case "hook_started": |
| 3232 | 3228 | yield* offerRuntimeEvent({ |
| 3233 | 3229 | ...base, |
| ... | ... | @@ -3786,7 +3782,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3786 | 3782 | |
| 3787 | 3783 | // Same reason as the approvals above: a request nobody can answer any more |
| 3788 | 3784 | // must not stay open, or the thread can never be settled. |
| 3789 | for (const pending of context.pendingUserInputs.values()) { | |
| 3785 | for (const pending of [...context.pendingUserInputs.values()]) { | |
| 3790 | 3786 | yield* pending.cancel; |
| 3791 | 3787 | } |
| 3792 | 3788 | |
| ... | ... | @@ -4694,16 +4690,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4694 | 4690 | }; |
| 4695 | 4691 | }); |
| 4696 | 4692 | |
| 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 | ||
| 4707 | 4693 | const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 4708 | 4694 | function* (threadId, _turnId) { |
| 4709 | 4695 | const context = yield* requireSession(threadId); |
| ... | ... | @@ -4818,7 +4804,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4818 | 4804 | }, |
| 4819 | 4805 | startSession, |
| 4820 | 4806 | sendTurn, |
| 4821 | compactThread, | |
| 4822 | 4807 | interruptTurn, |
| 4823 | 4808 | readThread, |
| 4824 | 4809 | rollbackThread, |
apps/server/src/provider/Layers/ClaudeProvider.ts+7-2| ... | ... | @@ -22,7 +22,6 @@ import { |
| 22 | 22 | |
| 23 | 23 | import { |
| 24 | 24 | buildServerProvider, |
| 25 | COMPACT_SLASH_COMMAND, | |
| 26 | 25 | DEFAULT_TIMEOUT_MS, |
| 27 | 26 | isCommandMissingCause, |
| 28 | 27 | parseGenericCliVersion, |
| ... | ... | @@ -504,7 +503,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 504 | 503 | ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) |
| 505 | 504 | : undefined; |
| 506 | 505 | const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); |
| 507 | const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; | |
| 506 | const slashCommands = [ | |
| 507 | { | |
| 508 | name: "compact", | |
| 509 | description: "Summarize the conversation and reduce context usage", | |
| 510 | }, | |
| 511 | ...(capabilities?.slashCommands ?? []), | |
| 512 | ]; | |
| 508 | 513 | const dedupedSlashCommands = dedupeSlashCommands(slashCommands); |
| 509 | 514 | |
| 510 | 515 | if (!capabilities) { |
apps/server/src/provider/Layers/CodexAdapter.test.ts-43| ... | ... | @@ -84,8 +84,6 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { |
| 84 | 84 | }), |
| 85 | 85 | ); |
| 86 | 86 | |
| 87 | public readonly compactThread = Effect.void; | |
| 88 | ||
| 89 | 87 | public readonly interruptTurnImpl = vi.fn( |
| 90 | 88 | (_turnId?: TurnId): Promise<void> => Promise.resolve(undefined), |
| 91 | 89 | ); |
| ... | ... | @@ -338,47 +336,6 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 338 | 336 | }), |
| 339 | 337 | ); |
| 340 | 338 | |
| 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 | ||
| 382 | 339 | it.effect("uploads feedback for the active Codex thread", () => |
| 383 | 340 | Effect.gen(function* () { |
| 384 | 341 | const adapter = yield* CodexAdapter; |
apps/server/src/provider/Layers/CodexAdapter.ts+1-23| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | type CanonicalItemType, |
| 12 | 12 | type CanonicalRequestType, |
| 13 | 13 | type CodexSettings, |
| 14 | EventId, | |
| 15 | 14 | ProviderDriverKind, |
| 16 | 15 | type ProviderEvent, |
| 17 | 16 | ProviderInstanceId, |
| ... | ... | @@ -1152,18 +1151,7 @@ function mapToRuntimeEvents( |
| 1152 | 1151 | ]; |
| 1153 | 1152 | } |
| 1154 | 1153 | const completed = mapItemLifecycle(event, canonicalThreadId, "item.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 | ]; | |
| 1154 | return completed ? [completed] : []; | |
| 1167 | 1155 | } |
| 1168 | 1156 | |
| 1169 | 1157 | if ( |
| ... | ... | @@ -1891,15 +1879,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1891 | 1879 | ), |
| 1892 | 1880 | ); |
| 1893 | 1881 | |
| 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 | ||
| 1903 | 1882 | const readThread: CodexAdapterShape["readThread"] = (threadId) => |
| 1904 | 1883 | requireSession(threadId).pipe( |
| 1905 | 1884 | Effect.flatMap((session) => session.runtime.readThread), |
| ... | ... | @@ -2035,7 +2014,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 2035 | 2014 | }, |
| 2036 | 2015 | startSession, |
| 2037 | 2016 | sendTurn, |
| 2038 | compactThread, | |
| 2039 | 2017 | interruptTurn, |
| 2040 | 2018 | readThread, |
| 2041 | 2019 | rollbackThread, |
apps/server/src/provider/Layers/CodexProvider.ts-2| ... | ... | @@ -30,7 +30,6 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts |
| 30 | 30 | import { |
| 31 | 31 | AUTH_PROBE_TIMEOUT_MS, |
| 32 | 32 | buildServerProvider, |
| 33 | COMPACT_SLASH_COMMAND, | |
| 34 | 33 | type ServerProviderDraft, |
| 35 | 34 | } from "../providerSnapshot.ts"; |
| 36 | 35 | import { expandHomePath } from "../../pathExpansion.ts"; |
| ... | ... | @@ -649,7 +648,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 649 | 648 | models: snapshot.models, |
| 650 | 649 | skills: snapshot.skills, |
| 651 | 650 | slashCommands: [ |
| 652 | COMPACT_SLASH_COMMAND, | |
| 653 | 651 | { |
| 654 | 652 | name: "feedback", |
| 655 | 653 | description: "Send this thread and Codex logs to OpenAI", |
apps/server/src/provider/Layers/CodexSessionRuntime.ts-5| ... | ... | @@ -196,7 +196,6 @@ export interface CodexSessionRuntimeShape { |
| 196 | 196 | readonly sendTurn: ( |
| 197 | 197 | input: CodexSessionRuntimeSendTurnInput, |
| 198 | 198 | ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>; |
| 199 | readonly compactThread: Effect.Effect<void, CodexSessionRuntimeError>; | |
| 200 | 199 | readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>; |
| 201 | 200 | readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>; |
| 202 | 201 | readonly rollbackThread: ( |
| ... | ... | @@ -2294,10 +2293,6 @@ export const makeCodexSessionRuntime = ( |
| 2294 | 2293 | return { |
| 2295 | 2294 | start, |
| 2296 | 2295 | getSession: Ref.get(sessionRef), |
| 2297 | compactThread: Effect.gen(function* () { | |
| 2298 | const providerThreadId = yield* readProviderThreadId; | |
| 2299 | yield* client.request("thread/compact/start", { threadId: providerThreadId }); | |
| 2300 | }), | |
| 2301 | 2296 | sendTurn: (input) => |
| 2302 | 2297 | Effect.gen(function* () { |
| 2303 | 2298 | const providerThreadId = yield* readProviderThreadId; |
apps/server/src/provider/Layers/CursorProvider.ts-2| ... | ... | @@ -34,7 +34,6 @@ import { |
| 34 | 34 | buildBooleanOptionDescriptor, |
| 35 | 35 | buildSelectOptionDescriptor, |
| 36 | 36 | buildServerProvider, |
| 37 | COMPACT_SLASH_COMMAND, | |
| 38 | 37 | collectStreamAsString, |
| 39 | 38 | isCommandMissingCause, |
| 40 | 39 | providerModelsFromSettings, |
| ... | ... | @@ -640,7 +639,6 @@ export function buildCursorProviderSnapshot(input: { |
| 640 | 639 | input.cursorSettings.customModels, |
| 641 | 640 | EMPTY_CAPABILITIES, |
| 642 | 641 | ), |
| 643 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 644 | 642 | probe: { |
| 645 | 643 | installed: true, |
| 646 | 644 | version: input.parsed.version, |
apps/server/src/provider/Layers/GrokProvider.ts-2| ... | ... | @@ -21,7 +21,6 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 21 | 21 | import { |
| 22 | 22 | AUTH_PROBE_TIMEOUT_MS, |
| 23 | 23 | buildServerProvider, |
| 24 | COMPACT_SLASH_COMMAND, | |
| 25 | 24 | isCommandMissingCause, |
| 26 | 25 | parseGenericCliVersion, |
| 27 | 26 | providerModelsFromSettings, |
| ... | ... | @@ -497,7 +496,6 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 497 | 496 | checkedAt, |
| 498 | 497 | models, |
| 499 | 498 | skills, |
| 500 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 501 | 499 | probe: { |
| 502 | 500 | installed: true, |
| 503 | 501 | version, |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts-39| ... | ... | @@ -79,7 +79,6 @@ 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>, | |
| 83 | 82 | promptAsyncError: null as Error | null, |
| 84 | 83 | promptAsyncImplementation: null as (() => Promise<void>) | null, |
| 85 | 84 | autoPromptEcho: true, |
| ... | ... | @@ -130,7 +129,6 @@ const runtimeMock = { |
| 130 | 129 | this.state.messageCalls.length = 0; |
| 131 | 130 | this.state.messageFailures = 0; |
| 132 | 131 | this.state.promptCalls.length = 0; |
| 133 | this.state.summarizeCalls.length = 0; | |
| 134 | 132 | this.state.promptAsyncError = null; |
| 135 | 133 | this.state.promptAsyncImplementation = null; |
| 136 | 134 | this.state.autoPromptEcho = true; |
| ... | ... | @@ -315,10 +313,6 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 315 | 313 | }); |
| 316 | 314 | } |
| 317 | 315 | }, |
| 318 | summarize: async (input: unknown) => { | |
| 319 | runtimeMock.state.summarizeCalls.push(input); | |
| 320 | return { data: true }; | |
| 321 | }, | |
| 322 | 316 | messages: async () => ({ data: runtimeMock.state.messages }), |
| 323 | 317 | message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { |
| 324 | 318 | runtimeMock.state.messageCalls.push({ sessionID, messageID }); |
| ... | ... | @@ -955,39 +949,6 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 955 | 949 | }), |
| 956 | 950 | ); |
| 957 | 951 | |
| 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 | ); | |
| 991 | 952 | it.effect("falls back to a fresh session when the persisted session is gone", () => |
| 992 | 953 | Effect.gen(function* () { |
| 993 | 954 | const adapter = yield* OpenCodeAdapter; |
apps/server/src/provider/Layers/OpenCodeAdapter.ts-81| ... | ... | @@ -2014,21 +2014,6 @@ 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 | } | |
| 2032 | 2017 | |
| 2033 | 2018 | case "message.updated": { |
| 2034 | 2019 | const promptAdmission = context.promptAdmission; |
| ... | ... | @@ -2999,71 +2984,6 @@ export function makeOpenCodeAdapter( |
| 2999 | 2984 | ); |
| 3000 | 2985 | }); |
| 3001 | 2986 | |
| 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 | }); | |
| 3067 | 2987 | const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( |
| 3068 | 2988 | function* (threadId, turnId) { |
| 3069 | 2989 | const context = yield* ensureSessionContext(sessions, threadId); |
| ... | ... | @@ -3348,7 +3268,6 @@ export function makeOpenCodeAdapter( |
| 3348 | 3268 | }, |
| 3349 | 3269 | startSession, |
| 3350 | 3270 | sendTurn, |
| 3351 | compactThread, | |
| 3352 | 3271 | interruptTurn, |
| 3353 | 3272 | respondToRequest, |
| 3354 | 3273 | respondToUserInput, |
apps/server/src/provider/Layers/OpenCodeProvider.ts-2| ... | ... | @@ -13,7 +13,6 @@ import { createModelCapabilities } from "@t3tools/shared/model"; |
| 13 | 13 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 14 | 14 | import { |
| 15 | 15 | buildServerProvider, |
| 16 | COMPACT_SLASH_COMMAND, | |
| 17 | 16 | nonEmptyTrimmed, |
| 18 | 17 | parseGenericCliVersion, |
| 19 | 18 | providerModelsFromSettings, |
| ... | ... | @@ -496,7 +495,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 496 | 495 | checkedAt, |
| 497 | 496 | models, |
| 498 | 497 | skills, |
| 499 | slashCommands: [COMPACT_SLASH_COMMAND], | |
| 500 | 498 | probe: { |
| 501 | 499 | installed: true, |
| 502 | 500 | version, |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+10-4| ... | ... | @@ -48,7 +48,6 @@ 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"; | |
| 52 | 51 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 53 | 52 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; |
| 54 | 53 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; |
| ... | ... | @@ -385,7 +384,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 385 | 384 | shortDescription: "Debug failing GitHub Actions checks", |
| 386 | 385 | }, |
| 387 | 386 | ]); |
| 388 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | |
| 387 | assert.deepStrictEqual(status.slashCommands, [ | |
| 389 | 388 | { |
| 390 | 389 | name: "feedback", |
| 391 | 390 | description: "Send this thread and Codex logs to OpenAI", |
| ... | ... | @@ -2440,7 +2439,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 2440 | 2439 | }), |
| 2441 | 2440 | ); |
| 2442 | 2441 | |
| 2443 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | |
| 2442 | assert.deepStrictEqual(status.slashCommands, [ | |
| 2443 | { | |
| 2444 | name: "compact", | |
| 2445 | description: "Summarize the conversation and reduce context usage", | |
| 2446 | }, | |
| 2444 | 2447 | { |
| 2445 | 2448 | name: "review", |
| 2446 | 2449 | description: "Review a pull request", |
| ... | ... | @@ -2484,7 +2487,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 2484 | 2487 | ); |
| 2485 | 2488 | |
| 2486 | 2489 | assert.deepStrictEqual(status.slashCommands, [ |
| 2487 | COMPACT_SLASH_COMMAND, | |
| 2490 | { | |
| 2491 | name: "compact", | |
| 2492 | description: "Summarize the conversation and reduce context usage", | |
| 2493 | }, | |
| 2488 | 2494 | { |
| 2489 | 2495 | name: "ui", |
| 2490 | 2496 | description: "Explore and refine UI", |
apps/server/src/provider/Layers/ProviderService.test.ts-52| ... | ... | @@ -172,18 +172,6 @@ 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 | ); | |
| 187 | 175 | const respondToRequest = vi.fn( |
| 188 | 176 | ( |
| 189 | 177 | _threadId: ThreadId, |
| ... | ... | @@ -262,7 +250,6 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { |
| 262 | 250 | }, |
| 263 | 251 | startSession, |
| 264 | 252 | sendTurn, |
| 265 | ...(provider === CODEX_DRIVER ? { compactThread } : {}), | |
| 266 | 253 | interruptTurn, |
| 267 | 254 | respondToRequest, |
| 268 | 255 | respondToUserInput, |
| ... | ... | @@ -299,7 +286,6 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { |
| 299 | 286 | updateSession, |
| 300 | 287 | startSession, |
| 301 | 288 | sendTurn, |
| 302 | compactThread, | |
| 303 | 289 | interruptTurn, |
| 304 | 290 | respondToRequest, |
| 305 | 291 | respondToUserInput, |
| ... | ... | @@ -1048,10 +1034,6 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1048 | 1034 | }); |
| 1049 | 1035 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); |
| 1050 | 1036 | |
| 1051 | yield* advanceTestClock(10); | |
| 1052 | yield* provider.compactThread(session.threadId); | |
| 1053 | assert.deepEqual(routing.codex.compactThread.mock.calls, [[session.threadId, undefined]]); | |
| 1054 | ||
| 1055 | 1037 | yield* provider.interruptTurn({ threadId: session.threadId }); |
| 1056 | 1038 | assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]); |
| 1057 | 1039 | |
| ... | ... | @@ -1115,40 +1097,6 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1115 | 1097 | }), |
| 1116 | 1098 | ); |
| 1117 | 1099 | |
| 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 | ||
| 1152 | 1100 | it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => |
| 1153 | 1101 | Effect.gen(function* () { |
| 1154 | 1102 | const provider = yield* ProviderService.ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+8-111| ... | ... | @@ -10,7 +10,6 @@ |
| 10 | 10 | * @module ProviderServiceLive |
| 11 | 11 | */ |
| 12 | 12 | import { |
| 13 | EventId, | |
| 14 | 13 | ModelSelection, |
| 15 | 14 | NonNegativeInt, |
| 16 | 15 | ThreadId, |
| ... | ... | @@ -29,7 +28,6 @@ import { |
| 29 | 28 | import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; |
| 30 | 29 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 31 | 30 | import * as DateTime from "effect/DateTime"; |
| 32 | import * as Deferred from "effect/Deferred"; | |
| 33 | 31 | import * as Effect from "effect/Effect"; |
| 34 | 32 | import * as Layer from "effect/Layer"; |
| 35 | 33 | import * as Option from "effect/Option"; |
| ... | ... | @@ -51,7 +49,6 @@ import { |
| 51 | 49 | providerTurnMetricAttributes, |
| 52 | 50 | withMetrics, |
| 53 | 51 | } from "../../observability/Metrics.ts"; |
| 54 | import { ProviderAdapterRequestError } from "../Errors.ts"; | |
| 55 | 52 | import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; |
| 56 | 53 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; |
| 57 | 54 | import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; |
| ... | ... | @@ -236,17 +233,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 236 | 233 | const revokeMcpCredential = |
| 237 | 234 | options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; |
| 238 | 235 | 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 | }; | |
| 250 | 236 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 251 | 237 | /** |
| 252 | 238 | * Attach the `t3-code` MCP server to the session that is about to start. |
| ... | ... | @@ -359,58 +345,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 359 | 345 | }, |
| 360 | 346 | event: ProviderRuntimeEvent, |
| 361 | 347 | ): Effect.Effect<void> => |
| 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 | }); | |
| 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 | ); | |
| 414 | 356 | |
| 415 | 357 | // `subscribedAdapters` is our source-of-truth for "which instance adapters |
| 416 | 358 | // are currently wired into the runtime event bus". It both tracks the set |
| ... | ... | @@ -912,50 +854,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 912 | 854 | ); |
| 913 | 855 | }); |
| 914 | 856 | |
| 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 | ||
| 959 | 857 | const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")( |
| 960 | 858 | function* (rawInput) { |
| 961 | 859 | const input = yield* decodeInputOrValidationError({ |
| ... | ... | @@ -1351,7 +1249,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 1351 | 1249 | return { |
| 1352 | 1250 | startSession, |
| 1353 | 1251 | sendTurn, |
| 1354 | compactThread, | |
| 1355 | 1252 | interruptTurn, |
| 1356 | 1253 | respondToRequest, |
| 1357 | 1254 | respondToUserInput, |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts-1| ... | ... | @@ -164,7 +164,6 @@ describe("ProviderSessionReaper", () => { |
| 164 | 164 | const providerService: ProviderServiceShape = { |
| 165 | 165 | startSession: () => unsupported(), |
| 166 | 166 | sendTurn: () => unsupported(), |
| 167 | compactThread: () => unsupported(), | |
| 168 | 167 | interruptTurn: () => unsupported(), |
| 169 | 168 | respondToRequest: () => unsupported(), |
| 170 | 169 | respondToUserInput: () => unsupported(), |
apps/server/src/provider/Services/ProviderAdapter.ts-5| ... | ... | @@ -68,11 +68,6 @@ 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 | ||
| 76 | 71 | /** |
| 77 | 72 | * Interrupt an active turn. |
| 78 | 73 | */ |
apps/server/src/provider/Services/ProviderService.ts-5| ... | ... | @@ -53,11 +53,6 @@ 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 | ||
| 61 | 56 | /** |
| 62 | 57 | * Interrupt a running provider turn. |
| 63 | 58 | */ |
apps/server/src/provider/providerSnapshot.ts-5| ... | ... | @@ -22,11 +22,6 @@ 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 | ||
| 30 | 25 | export interface CommandResult { |
| 31 | 26 | readonly stdout: string; |
| 32 | 27 | readonly stderr: string; |
apps/server/src/serverRuntimeStartup.reconcile.test.ts-1| ... | ... | @@ -51,7 +51,6 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) => |
| 51 | 51 | ({ |
| 52 | 52 | startSession: () => Effect.die("unused"), |
| 53 | 53 | sendTurn: () => Effect.die("unused"), |
| 54 | compactThread: () => Effect.die("unused"), | |
| 55 | 54 | interruptTurn: () => Effect.die("unused"), |
| 56 | 55 | respondToRequest: () => Effect.die("unused"), |
| 57 | 56 | respondToUserInput: () => Effect.die("unused"), |
apps/web/src/components/ChatView.tsx+19-21| ... | ... | @@ -328,7 +328,7 @@ import { |
| 328 | 328 | import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 329 | 329 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 330 | 330 | import { |
| 331 | hasAvailableCompactionProvider, | |
| 331 | hasAvailableClaudeCompactionProvider, | |
| 332 | 332 | hasDismissedResumeCompaction, |
| 333 | 333 | shouldOfferResumeCompaction, |
| 334 | 334 | } from "./chat/ContextWindowMeter.logic"; |
| ... | ... | @@ -2947,23 +2947,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 2947 | 2947 | activeThread?.modelSelection.instanceId ?? |
| 2948 | 2948 | activeProject?.defaultModelSelection?.instanceId ?? |
| 2949 | 2949 | null; |
| 2950 | const activeProviderStatus = useMemo(() => { | |
| 2951 | if (activeProviderInstanceId) { | |
| 2952 | return ( | |
| 2953 | providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null | |
| 2954 | ); | |
| 2955 | } | |
| 2956 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); | |
| 2957 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; | |
| 2958 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); | |
| 2959 | const manualCompactionProviderAvailable = useMemo( | |
| 2950 | const compactionProviderAvailable = useMemo( | |
| 2960 | 2951 | () => |
| 2961 | hasAvailableCompactionProvider({ | |
| 2952 | hasAvailableClaudeCompactionProvider({ | |
| 2962 | 2953 | providers: applyProviderInstanceSettings( |
| 2963 | 2954 | deriveProviderInstanceEntries(providerStatuses), |
| 2964 | 2955 | settings, |
| 2965 | 2956 | ), |
| 2966 | driverKind: selectedProvider, | |
| 2967 | 2957 | instanceId: activeProviderInstanceId, |
| 2968 | 2958 | lockedInstanceId: lockedProvider |
| 2969 | 2959 | ? (activeThread?.session?.providerInstanceId ?? |
| ... | ... | @@ -2977,10 +2967,18 @@ function ChatViewContent(props: ChatViewProps) { |
| 2977 | 2967 | activeThread?.session?.providerInstanceId, |
| 2978 | 2968 | lockedProvider, |
| 2979 | 2969 | providerStatuses, |
| 2980 | selectedProvider, | |
| 2981 | 2970 | settings, |
| 2982 | 2971 | ], |
| 2983 | 2972 | ); |
| 2973 | const activeProviderStatus = useMemo(() => { | |
| 2974 | if (activeProviderInstanceId) { | |
| 2975 | return ( | |
| 2976 | providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null | |
| 2977 | ); | |
| 2978 | } | |
| 2979 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); | |
| 2980 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; | |
| 2981 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); | |
| 2984 | 2982 | const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = |
| 2985 | 2983 | useLocalStorage( |
| 2986 | 2984 | `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, |
| ... | ... | @@ -5246,11 +5244,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 5246 | 5244 | activeThread && activeContextWindow |
| 5247 | 5245 | ? `${activeThread.id}:${activeContextWindow.updatedAt}` |
| 5248 | 5246 | : null; |
| 5249 | const compactThreadUnavailable = | |
| 5247 | const compactDisabled = | |
| 5250 | 5248 | !activeThread || |
| 5251 | 5249 | !activeProject || |
| 5252 | 5250 | !isServerThread || |
| 5253 | !manualCompactionProviderAvailable || | |
| 5251 | selectedProvider !== "claudeAgent" || | |
| 5252 | !compactionProviderAvailable || | |
| 5254 | 5253 | isWorking || |
| 5255 | 5254 | threadDetailLoading || |
| 5256 | 5255 | isPreparingWorktree || |
| ... | ... | @@ -5258,15 +5257,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 5258 | 5257 | feedbackUploading || |
| 5259 | 5258 | pendingApprovals.length > 0 || |
| 5260 | 5259 | pendingUserInputs.length > 0 || |
| 5261 | showPlanFollowUpPrompt; | |
| 5262 | const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; | |
| 5260 | showPlanFollowUpPrompt || | |
| 5261 | composerHasUnsentContent; | |
| 5263 | 5262 | const compactDisabledReason = compactDisabled |
| 5264 | 5263 | ? composerHasUnsentContent |
| 5265 | 5264 | ? "Send or clear your draft before compacting" |
| 5266 | 5265 | : !activeProject |
| 5267 | 5266 | ? "Choose a project before compacting" |
| 5268 | : !manualCompactionProviderAvailable | |
| 5269 | ? "Compaction is unavailable for this provider" | |
| 5267 | : !compactionProviderAvailable | |
| 5268 | ? "Enable a Claude provider before compacting" | |
| 5270 | 5269 | : "Compacting is unavailable right now" |
| 5271 | 5270 | : null; |
| 5272 | 5271 | const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| ... | ... | @@ -7650,7 +7649,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 7650 | 7649 | } |
| 7651 | 7650 | activeThreadModelSelection={activeThread?.modelSelection} |
| 7652 | 7651 | activeContextWindow={activeContextWindow} |
| 7653 | compactThreadUnavailable={compactThreadUnavailable} | |
| 7654 | 7652 | compactDisabled={compactDisabled} |
| 7655 | 7653 | compactDisabledReason={compactDisabledReason} |
| 7656 | 7654 | resolvedTheme={resolvedTheme} |
apps/web/src/components/chat/ChatComposer.tsx+8-23| ... | ... | @@ -163,10 +163,7 @@ import { |
| 163 | 163 | renderProviderTraitsPicker, |
| 164 | 164 | } from "./composerProviderState"; |
| 165 | 165 | import { ContextWindowMeter } from "./ContextWindowMeter"; |
| 166 | import { | |
| 167 | providerSupportsManualCompaction, | |
| 168 | resolveContextWindowModelDisplayName, | |
| 169 | } from "./ContextWindowMeter.logic"; | |
| 166 | import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; | |
| 170 | 167 | import { |
| 171 | 168 | attachVideoThumbnail, |
| 172 | 169 | buildExpandedImagePreview, |
| ... | ... | @@ -694,7 +691,6 @@ export interface ChatComposerProps { |
| 694 | 691 | |
| 695 | 692 | // Context window |
| 696 | 693 | activeContextWindow: ContextWindowSnapshot | null; |
| 697 | compactThreadUnavailable: boolean; | |
| 698 | 694 | compactDisabled: boolean; |
| 699 | 695 | compactDisabledReason: string | null; |
| 700 | 696 | |
| ... | ... | @@ -791,7 +787,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 791 | 787 | activeProjectDefaultModelSelection, |
| 792 | 788 | activeThreadModelSelection, |
| 793 | 789 | activeContextWindow, |
| 794 | compactThreadUnavailable, | |
| 795 | 790 | compactDisabled, |
| 796 | 791 | compactDisabledReason, |
| 797 | 792 | resolvedTheme, |
| ... | ... | @@ -1125,7 +1120,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1125 | 1120 | () => selectedProviderEntry?.snapshot ?? null, |
| 1126 | 1121 | [selectedProviderEntry], |
| 1127 | 1122 | ); |
| 1128 | const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry); | |
| 1129 | 1123 | const selectedProviderSkills = selectedProviderStatus |
| 1130 | 1124 | ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) |
| 1131 | 1125 | : []; |
| ... | ... | @@ -1354,6 +1348,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1354 | 1348 | prompt, |
| 1355 | 1349 | ], |
| 1356 | 1350 | ); |
| 1351 | ||
| 1357 | 1352 | // ------------------------------------------------------------------ |
| 1358 | 1353 | // Derived: composer trigger / menu |
| 1359 | 1354 | // ------------------------------------------------------------------ |
| ... | ... | @@ -1365,16 +1360,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1365 | 1360 | cwd: isPathTrigger ? gitCwd : null, |
| 1366 | 1361 | query: isPathTrigger ? pathTriggerQuery : null, |
| 1367 | 1362 | }); |
| 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; | |
| 1378 | 1363 | |
| 1379 | 1364 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 1380 | 1365 | if (!composerTrigger) return []; |
| ... | ... | @@ -1443,11 +1428,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1443 | 1428 | skill.description ?? |
| 1444 | 1429 | (skill.scope ? `${skill.scope} skill` : ""), |
| 1445 | 1430 | })); |
| 1446 | const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( | |
| 1447 | (item) => item.command.name !== "compact" || compactSlashCommandAvailable, | |
| 1448 | ); | |
| 1449 | 1431 | const slashCommandItems = slashCommandItemsForPromptPosition( |
| 1450 | [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], | |
| 1432 | [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems], | |
| 1451 | 1433 | composerTrigger.rangeStart === 0, |
| 1452 | 1434 | ); |
| 1453 | 1435 | return searchSlashCommandItems(slashCommandItems, query); |
| ... | ... | @@ -1467,7 +1449,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1467 | 1449 | } |
| 1468 | 1450 | return []; |
| 1469 | 1451 | }, [ |
| 1470 | compactSlashCommandAvailable, | |
| 1471 | 1452 | composerTrigger, |
| 1472 | 1453 | planModeUiEnabled, |
| 1473 | 1454 | selectedProvider, |
| ... | ... | @@ -2317,6 +2298,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2317 | 2298 | if ( |
| 2318 | 2299 | compactDisabled || |
| 2319 | 2300 | noProviderAvailable || |
| 2301 | composerSendState.hasSendableContent || | |
| 2320 | 2302 | activePendingApproval !== null || |
| 2321 | 2303 | pendingUserInputs.length > 0 || |
| 2322 | 2304 | phase === "running" || |
| ... | ... | @@ -2354,6 +2336,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2354 | 2336 | activeThreadId, |
| 2355 | 2337 | compactDisabled, |
| 2356 | 2338 | composerDraftTarget, |
| 2339 | composerSendState.hasSendableContent, | |
| 2357 | 2340 | isConnecting, |
| 2358 | 2341 | isSendBusy, |
| 2359 | 2342 | noProviderAvailable, |
| ... | ... | @@ -4419,7 +4402,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4419 | 4402 | compactDisabled || noProviderAvailable || isSendBusy || isConnecting |
| 4420 | 4403 | } |
| 4421 | 4404 | compactDisabledReason={resolvedCompactDisabledReason} |
| 4422 | {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} | |
| 4405 | {...(selectedProvider === "claudeAgent" | |
| 4406 | ? { onCompactContext: compactThreadContext } | |
| 4407 | : {})} | |
| 4423 | 4408 | /> |
| 4424 | 4409 | </div> |
| 4425 | 4410 | </div> |
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+5-7| ... | ... | @@ -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 | hasAvailableCompactionProvider, | |
| 6 | hasAvailableClaudeCompactionProvider, | |
| 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: [{ name: "compact", description: "" }], | |
| 28 | slashCommands: [], | |
| 29 | 29 | skills: [], |
| 30 | 30 | }; |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | describe("hasAvailableCompactionProvider", () => { | |
| 33 | describe("hasAvailableClaudeCompactionProvider", () => { | |
| 34 | 34 | const originalInstanceId = ProviderInstanceId.make("claude_original"); |
| 35 | 35 | |
| 36 | 36 | it("rejects a fallback in a different locked continuation group", () => { |
| ... | ... | @@ -47,9 +47,8 @@ describe("hasAvailableCompactionProvider", () => { |
| 47 | 47 | ]); |
| 48 | 48 | |
| 49 | 49 | expect( |
| 50 | hasAvailableCompactionProvider({ | |
| 50 | hasAvailableClaudeCompactionProvider({ | |
| 51 | 51 | providers, |
| 52 | driverKind: ProviderDriverKind.make("claudeAgent"), | |
| 53 | 52 | instanceId: originalInstanceId, |
| 54 | 53 | lockedInstanceId: originalInstanceId, |
| 55 | 54 | }), |
| ... | ... | @@ -70,9 +69,8 @@ describe("hasAvailableCompactionProvider", () => { |
| 70 | 69 | ]); |
| 71 | 70 | |
| 72 | 71 | expect( |
| 73 | hasAvailableCompactionProvider({ | |
| 72 | hasAvailableClaudeCompactionProvider({ | |
| 74 | 73 | providers, |
| 75 | driverKind: ProviderDriverKind.make("claudeAgent"), | |
| 76 | 74 | instanceId: originalInstanceId, |
| 77 | 75 | lockedInstanceId: originalInstanceId, |
| 78 | 76 | }), |
apps/web/src/components/chat/ContextWindowMeter.logic.ts+10-16| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; | |
| 1 | import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, |
| 4 | 4 | isClaudeResumeCompactionQuestion, |
| ... | ... | @@ -12,33 +12,27 @@ 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 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: { | |
| 15 | export function hasAvailableClaudeCompactionProvider(input: { | |
| 22 | 16 | readonly providers: ReadonlyArray<ProviderInstanceEntry>; |
| 23 | readonly driverKind: ProviderDriverKind; | |
| 24 | 17 | readonly instanceId: ProviderInstanceId | null; |
| 25 | 18 | readonly lockedInstanceId: ProviderInstanceId | null; |
| 26 | 19 | }): boolean { |
| 27 | const driverProviders = input.providers.filter( | |
| 28 | (provider) => provider.driverKind === input.driverKind, | |
| 20 | const claudeProviders = input.providers.filter( | |
| 21 | (provider) => provider.driverKind === "claudeAgent", | |
| 29 | 22 | ); |
| 30 | 23 | const lockedContinuationGroupKey = input.lockedInstanceId |
| 31 | ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId) | |
| 24 | ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId) | |
| 32 | 25 | ?.continuationGroupKey |
| 33 | 26 | : undefined; |
| 34 | 27 | const compatibleProviders = lockedContinuationGroupKey |
| 35 | ? driverProviders.filter( | |
| 28 | ? claudeProviders.filter( | |
| 36 | 29 | (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, |
| 37 | 30 | ) |
| 38 | : driverProviders; | |
| 31 | : claudeProviders; | |
| 39 | 32 | |
| 40 | return providerSupportsManualCompaction( | |
| 41 | resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined), | |
| 33 | return ( | |
| 34 | resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !== | |
| 35 | undefined | |
| 42 | 36 | ); |
| 43 | 37 | } |
| 44 | 38 |
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: "Compacted context 899K → 19K tokens", | |
| 922 | label: "Context compacted", | |
| 923 | 923 | tone: "info", |
| 924 | 924 | }, |
| 925 | 925 | }, |
| ... | ... | @@ -927,7 +927,7 @@ describe("MessagesTimeline", () => { |
| 927 | 927 | />, |
| 928 | 928 | ); |
| 929 | 929 | |
| 930 | expect(markup).toContain("Compacted context 899K → 19K tokens"); | |
| 930 | expect(markup).toContain("Context compacted"); | |
| 931 | 931 | }); |
| 932 | 932 | |
| 933 | 933 | it("summarizes changed files in one line", () => { |
docs/user/composer.md-2| ... | ... | @@ -196,8 +196,6 @@ 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 | ||
| 201 | 199 | By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in |
| 202 | 200 | slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the |
| 203 | 201 | same `$name` skill token to your message. The original skill name remains searchable. If the provider |
packages/contracts/src/providerRuntime.ts-2| ... | ... | @@ -298,8 +298,6 @@ 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), | |
| 303 | 301 | detail: Schema.optional(Schema.Unknown), |
| 304 | 302 | }); |
| 305 | 303 | export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; |