authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 18:43:46-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 18:43:46-04:00
log535557b3f785629e6f48cad40c5a4b78b3b3c5d6
tree7384a53e54752305fcc8062abd0d4fe44aa6b814
parent1eb36b45ef7aa5c028380afb7266bcabf258a55e
signature Signed by PGP key B5690EEEBB952194

feat(providers): add context compaction across harnesses (#8808)

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({
127127 // Codex feedback uploads an existing thread's session and logs.
128128 if (
129129 !hasThread &&
130 selectedProviderStatus?.driver === "codex" &&
131 command.name === "feedback"
130 (command.name === "compact" ||
131 (selectedProviderStatus?.driver === "codex" && command.name === "feedback"))
132132 ) {
133133 continue;
134134 }
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1
......@@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll(
110110 Layer.succeed(ProviderService.ProviderService, {
111111 startSession: () => Effect.die("unused"),
112112 sendTurn: () => Effect.die("unused"),
113 compactThread: () => Effect.die("unused"),
113114 interruptTurn: () => Effect.die("unused"),
114115 respondToRequest: () => Effect.die("unused"),
115116 respondToUserInput: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1
......@@ -107,6 +107,7 @@ function createProviderServiceHarness(
107107 const service: ProviderServiceShape = {
108108 startSession: () => unsupported(),
109109 sendTurn: () => unsupported(),
110 compactThread: () => unsupported(),
110111 interruptTurn: () => unsupported(),
111112 respondToRequest: () => unsupported(),
112113 respondToUserInput: () => unsupported(),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+15
......@@ -1275,6 +1275,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
12751275 return;
12761276 }
12771277
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
12781293 case "thread.session-set": {
12791294 const turnId = event.payload.session.activeTurnId;
12801295 if (turnId === null || event.payload.session.status !== "running") {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+24
......@@ -254,6 +254,7 @@ describe("ProviderCommandReactor", () => {
254254 turnId: asTurnId("turn-1"),
255255 }),
256256 );
257 const compactThread = vi.fn((_: ThreadId) => Effect.void);
257258 const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void);
258259 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
259260 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
......@@ -339,6 +340,7 @@ describe("ProviderCommandReactor", () => {
339340 const service: ProviderServiceShape = {
340341 startSession: startSession as ProviderServiceShape["startSession"],
341342 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
343 compactThread,
342344 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
343345 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
344346 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
......@@ -533,6 +535,7 @@ describe("ProviderCommandReactor", () => {
533535 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
534536 startSession,
535537 sendTurn,
538 compactThread,
536539 interruptTurn,
537540 respondToRequest,
538541 respondToUserInput,
......@@ -632,6 +635,27 @@ describe("ProviderCommandReactor", () => {
632635 }),
633636 );
634637
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 );
635659 effectIt.effect("projects starting before a slow provider session finishes", () =>
636660 Effect.gen(function* () {
637661 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";
3030
3131import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
3232import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
33import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
33import {
34 ProviderAdapterRequestError,
35 ProviderAdapterValidationError,
36} from "../../provider/Errors.ts";
3437import type { ProviderServiceError } from "../../provider/Errors.ts";
3538import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
3639import { ProviderService } from "../../provider/Services/ProviderService.ts";
......@@ -50,6 +53,7 @@ import {
5053import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
5154import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
5255const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
56const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
5357const isProviderDriverKind = Schema.is(ProviderDriverKind);
5458
5559type ProviderIntentEvent = Extract<
......@@ -72,6 +76,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined
7276 return normalized && normalized.length > 0 ? normalized : undefined;
7377}
7478
79const isCompactCommandMessage = (message: ThreadTitleMessage): boolean =>
80 message.role === "user" &&
81 (message.attachments?.length ?? 0) === 0 &&
82 message.text.trim().toLowerCase() === "/compact";
7583function mapProviderSessionStatusToOrchestrationStatus(
7684 status: "connecting" | "ready" | "running" | "error" | "closed",
7785): OrchestrationSession["status"] {
......@@ -332,6 +340,7 @@ const make = Effect.gen(function* () {
332340 );
333341
334342 const threadModelSelections = new Map<string, ModelSelection>();
343 const compactingThreadIds = new Set<ThreadId>();
335344
336345 const appendProviderFailureActivity = (input: {
337346 readonly threadId: ThreadId;
......@@ -375,11 +384,11 @@ const make = Effect.gen(function* () {
375384
376385 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {
377386 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;
383392 }
384393 return Cause.pretty(cause);
385394 };
......@@ -1137,7 +1146,6 @@ const make = Effect.gen(function* () {
11371146 if (!thread) {
11381147 return;
11391148 }
1140
11411149 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);
11421150 if (!message || message.role !== "user") {
11431151 yield* appendProviderFailureActivity({
......@@ -1147,15 +1155,28 @@ const make = Effect.gen(function* () {
11471155 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,
11481156 turnId: null,
11491157 createdAt: event.payload.createdAt,
1158 requestId: event.payload.messageId,
11501159 });
11511160 return;
11521161 }
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 });
11531172
11541173 yield* ensureThreadWorktree(thread);
11551174
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) {
11591180 const project = yield* resolveProject(thread.projectId);
11601181 const generationCwd =
11611182 resolveThreadWorkspaceCwd({
......@@ -1194,16 +1215,7 @@ const make = Effect.gen(function* () {
11941215 detail,
11951216 createdAt: event.payload.createdAt,
11961217 }).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)),
12071219 Effect.asVoid,
12081220 );
12091221 };
......@@ -1220,6 +1232,69 @@ const make = Effect.gen(function* () {
12201232 ),
12211233 );
12221234
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 }
12231298 const sendTurnRequest = yield* buildSendTurnRequestForThread({
12241299 threadId: event.payload.threadId,
12251300 messageText: message.text,
......@@ -1240,7 +1315,7 @@ const make = Effect.gen(function* () {
12401315
12411316 yield* providerService
12421317 .sendTurn(sendTurnRequest.value)
1243 .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
1318 .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
12441319 });
12451320
12461321 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+4-1
......@@ -105,6 +105,7 @@ function createProviderServiceHarness() {
105105 const service: ProviderServiceShape = {
106106 startSession: () => unsupported(),
107107 sendTurn: () => unsupported(),
108 compactThread: () => unsupported(),
108109 interruptTurn: () => unsupported(),
109110 respondToRequest: () => unsupported(),
110111 respondToUserInput: () => unsupported(),
......@@ -3221,6 +3222,8 @@ describe("ProviderRuntimeIngestion", () => {
32213222 turnId: asTurnId("turn-1"),
32223223 payload: {
32233224 state: "compacted",
3225 beforeTokens: 899_000,
3226 afterTokens: 19_000,
32243227 detail: { source: "provider" },
32253228 },
32263229 });
......@@ -3234,7 +3237,7 @@ describe("ProviderRuntimeIngestion", () => {
32343237 const activity = thread.activities.find(
32353238 (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction",
32363239 );
3237 expect(activity?.summary).toBe("Context compacted");
3240 expect(activity?.summary).toBe("Compacted context 899K → 19K tokens");
32383241 expect(activity?.tone).toBe("info");
32393242 });
32403243
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+10-1
......@@ -28,6 +28,7 @@ import * as Layer from "effect/Layer";
2828import * as Option from "effect/Option";
2929import * as Stream from "effect/Stream";
3030import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
31import { formatTokens } from "@t3tools/shared/usageFormat";
3132
3233import { ProviderService } from "../../provider/Services/ProviderService.ts";
3334import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
......@@ -753,15 +754,23 @@ export function runtimeEventToActivities(
753754 return [];
754755 }
755756
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";
756763 return [
757764 {
758765 id: event.eventId,
759766 createdAt: event.createdAt,
760767 tone: "info",
761768 kind: "context-compaction",
762 summary: "Context compacted",
769 summary,
763770 payload: {
764771 state: event.payload.state,
772 ...(beforeTokens !== undefined ? { beforeTokens } : {}),
773 ...(afterTokens !== undefined ? { afterTokens } : {}),
765774 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
766775 },
767776 turnId: toTurnId(event.turnId) ?? null,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+7-6
......@@ -729,12 +729,7 @@ describe("ClaudeAdapterLive", () => {
729729 runtimeMode: "full-access",
730730 });
731731
732 yield* adapter.sendTurn({
733 threadId: session.threadId,
734 input: "/compact",
735 attachments: [],
736 modelSelection,
737 });
732 yield* adapter.compactThread!(session.threadId, modelSelection);
738733
739734 const promptText = yield* Effect.promise(() =>
740735 readFirstPromptText(harness.getLastCreateQueryInput()),
......@@ -2232,6 +2227,12 @@ describe("ClaudeAdapterLive", () => {
22322227 } as unknown as SDKMessage);
22332228
22342229 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);
22352236 const finalUsageEvent = runtimeEvents.findLast(
22362237 (event) => event.type === "thread.token-usage.updated",
22372238 );
apps/server/src/provider/Layers/ClaudeAdapter.ts+28-13
......@@ -3198,32 +3198,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
31983198 },
31993199 });
32003200 return;
3201 case "compact_boundary":
3201 case "compact_boundary": {
32023202 if (context.turnState) {
32033203 context.turnState.latestAssistantUsage = undefined;
32043204 context.turnState.compactedSinceLatestAssistantUsage = true;
32053205 }
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,
32173210 );
3211 yield* emitThreadTokenUsage(context, compactedUsage, {
3212 rawMethod: "claude/system/compact_boundary",
3213 rawPayload: message,
3214 });
32183215 yield* offerRuntimeEvent({
32193216 ...base,
32203217 type: "thread.state.changed",
32213218 payload: {
32223219 state: "compacted",
3220 ...(compactedUsage?.lastUsedTokens !== undefined
3221 ? { beforeTokens: compactedUsage.lastUsedTokens }
3222 : {}),
3223 ...(compactedUsage?.usedTokens !== undefined
3224 ? { afterTokens: compactedUsage.usedTokens }
3225 : {}),
32233226 detail: message,
32243227 },
32253228 });
32263229 return;
3230 }
32273231 case "hook_started":
32283232 yield* offerRuntimeEvent({
32293233 ...base,
......@@ -3782,7 +3786,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37823786
37833787 // Same reason as the approvals above: a request nobody can answer any more
37843788 // 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()) {
37863790 yield* pending.cancel;
37873791 }
37883792
......@@ -4690,6 +4694,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
46904694 };
46914695 });
46924696
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
46934707 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
46944708 function* (threadId, _turnId) {
46954709 const context = yield* requireSession(threadId);
......@@ -4804,6 +4818,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
48044818 },
48054819 startSession,
48064820 sendTurn,
4821 compactThread,
48074822 interruptTurn,
48084823 readThread,
48094824 rollbackThread,
apps/server/src/provider/Layers/ClaudeProvider.ts+2-7
......@@ -22,6 +22,7 @@ import {
2222
2323import {
2424 buildServerProvider,
25 COMPACT_SLASH_COMMAND,
2526 DEFAULT_TIMEOUT_MS,
2627 isCommandMissingCause,
2728 parseGenericCliVersion,
......@@ -503,13 +504,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
503504 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
504505 : undefined;
505506 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 ?? [])];
513508 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
514509
515510 if (!capabilities) {
apps/server/src/provider/Layers/CodexAdapter.test.ts+43
......@@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
8484 }),
8585 );
8686
87 public readonly compactThread = Effect.void;
88
8789 public readonly interruptTurnImpl = vi.fn(
8890 (_turnId?: TurnId): Promise<void> => Promise.resolve(undefined),
8991 );
......@@ -336,6 +338,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
336338 }),
337339 );
338340
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
339382 it.effect("uploads feedback for the active Codex thread", () =>
340383 Effect.gen(function* () {
341384 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+23-1
......@@ -11,6 +11,7 @@ import {
1111 type CanonicalItemType,
1212 type CanonicalRequestType,
1313 type CodexSettings,
14 EventId,
1415 ProviderDriverKind,
1516 type ProviderEvent,
1617 ProviderInstanceId,
......@@ -1151,7 +1152,18 @@ function mapToRuntimeEvents(
11511152 ];
11521153 }
11531154 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 ];
11551167 }
11561168
11571169 if (
......@@ -1879,6 +1891,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
18791891 ),
18801892 );
18811893
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
18821903 const readThread: CodexAdapterShape["readThread"] = (threadId) =>
18831904 requireSession(threadId).pipe(
18841905 Effect.flatMap((session) => session.runtime.readThread),
......@@ -2014,6 +2035,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
20142035 },
20152036 startSession,
20162037 sendTurn,
2038 compactThread,
20172039 interruptTurn,
20182040 readThread,
20192041 rollbackThread,
apps/server/src/provider/Layers/CodexProvider.ts+2
......@@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts
3030import {
3131 AUTH_PROBE_TIMEOUT_MS,
3232 buildServerProvider,
33 COMPACT_SLASH_COMMAND,
3334 type ServerProviderDraft,
3435} from "../providerSnapshot.ts";
3536import { expandHomePath } from "../../pathExpansion.ts";
......@@ -648,6 +649,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
648649 models: snapshot.models,
649650 skills: snapshot.skills,
650651 slashCommands: [
652 COMPACT_SLASH_COMMAND,
651653 {
652654 name: "feedback",
653655 description: "Send this thread and Codex logs to OpenAI",
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5
......@@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape {
196196 readonly sendTurn: (
197197 input: CodexSessionRuntimeSendTurnInput,
198198 ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>;
199 readonly compactThread: Effect.Effect<void, CodexSessionRuntimeError>;
199200 readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>;
200201 readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;
201202 readonly rollbackThread: (
......@@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = (
22932294 return {
22942295 start,
22952296 getSession: Ref.get(sessionRef),
2297 compactThread: Effect.gen(function* () {
2298 const providerThreadId = yield* readProviderThreadId;
2299 yield* client.request("thread/compact/start", { threadId: providerThreadId });
2300 }),
22962301 sendTurn: (input) =>
22972302 Effect.gen(function* () {
22982303 const providerThreadId = yield* readProviderThreadId;
apps/server/src/provider/Layers/CursorProvider.ts+2
......@@ -34,6 +34,7 @@ import {
3434 buildBooleanOptionDescriptor,
3535 buildSelectOptionDescriptor,
3636 buildServerProvider,
37 COMPACT_SLASH_COMMAND,
3738 collectStreamAsString,
3839 isCommandMissingCause,
3940 providerModelsFromSettings,
......@@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: {
639640 input.cursorSettings.customModels,
640641 EMPTY_CAPABILITIES,
641642 ),
643 slashCommands: [COMPACT_SLASH_COMMAND],
642644 probe: {
643645 installed: true,
644646 version: input.parsed.version,
apps/server/src/provider/Layers/GrokProvider.ts+2
......@@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
2121import {
2222 AUTH_PROBE_TIMEOUT_MS,
2323 buildServerProvider,
24 COMPACT_SLASH_COMMAND,
2425 isCommandMissingCause,
2526 parseGenericCliVersion,
2627 providerModelsFromSettings,
......@@ -496,6 +497,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
496497 checkedAt,
497498 models,
498499 skills,
500 slashCommands: [COMPACT_SLASH_COMMAND],
499501 probe: {
500502 installed: true,
501503 version,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+39
......@@ -79,6 +79,7 @@ const runtimeMock = {
7979 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
8080 messageFailures: 0,
8181 promptCalls: [] as Array<unknown>,
82 summarizeCalls: [] as Array<unknown>,
8283 promptAsyncError: null as Error | null,
8384 promptAsyncImplementation: null as (() => Promise<void>) | null,
8485 autoPromptEcho: true,
......@@ -129,6 +130,7 @@ const runtimeMock = {
129130 this.state.messageCalls.length = 0;
130131 this.state.messageFailures = 0;
131132 this.state.promptCalls.length = 0;
133 this.state.summarizeCalls.length = 0;
132134 this.state.promptAsyncError = null;
133135 this.state.promptAsyncImplementation = null;
134136 this.state.autoPromptEcho = true;
......@@ -313,6 +315,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
313315 });
314316 }
315317 },
318 summarize: async (input: unknown) => {
319 runtimeMock.state.summarizeCalls.push(input);
320 return { data: true };
321 },
316322 messages: async () => ({ data: runtimeMock.state.messages }),
317323 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
318324 runtimeMock.state.messageCalls.push({ sessionID, messageID });
......@@ -949,6 +955,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
949955 }),
950956 );
951957
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 );
952991 it.effect("falls back to a fresh session when the persisted session is gone", () =>
953992 Effect.gen(function* () {
954993 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+81
......@@ -2014,6 +2014,21 @@ export function makeOpenCodeAdapter(
20142014 }
20152015 break;
20162016 }
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 }
20172032
20182033 case "message.updated": {
20192034 const promptAdmission = context.promptAdmission;
......@@ -2984,6 +2999,71 @@ export function makeOpenCodeAdapter(
29842999 );
29853000 });
29863001
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 });
29873067 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
29883068 function* (threadId, turnId) {
29893069 const context = yield* ensureSessionContext(sessions, threadId);
......@@ -3268,6 +3348,7 @@ export function makeOpenCodeAdapter(
32683348 },
32693349 startSession,
32703350 sendTurn,
3351 compactThread,
32713352 interruptTurn,
32723353 respondToRequest,
32733354 respondToUserInput,
apps/server/src/provider/Layers/OpenCodeProvider.ts+2
......@@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";
1313import { compareSemverVersions } from "@t3tools/shared/semver";
1414import {
1515 buildServerProvider,
16 COMPACT_SLASH_COMMAND,
1617 nonEmptyTrimmed,
1718 parseGenericCliVersion,
1819 providerModelsFromSettings,
......@@ -495,6 +496,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
495496 checkedAt,
496497 models,
497498 skills,
499 slashCommands: [COMPACT_SLASH_COMMAND],
498500 probe: {
499501 installed: true,
500502 version,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+4-10
......@@ -48,6 +48,7 @@ import {
4848import * as ServerConfig from "../../config.ts";
4949import * as ServerSettingsModule from "../../serverSettings.ts";
5050import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";
51import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
5152import type { ProviderInstance } from "../ProviderDriver.ts";
5253import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
5354import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
......@@ -384,7 +385,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
384385 shortDescription: "Debug failing GitHub Actions checks",
385386 },
386387 ]);
387 assert.deepStrictEqual(status.slashCommands, [
388 assert.deepStrictEqual(status.slashCommands.slice(1), [
388389 {
389390 name: "feedback",
390391 description: "Send this thread and Codex logs to OpenAI",
......@@ -2439,11 +2440,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
24392440 }),
24402441 );
24412442
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), [
24472444 {
24482445 name: "review",
24492446 description: "Review a pull request",
......@@ -2487,10 +2484,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
24872484 );
24882485
24892486 assert.deepStrictEqual(status.slashCommands, [
2490 {
2491 name: "compact",
2492 description: "Summarize the conversation and reduce context usage",
2493 },
2487 COMPACT_SLASH_COMMAND,
24942488 {
24952489 name: "ui",
24962490 description: "Explore and refine UI",
apps/server/src/provider/Layers/ProviderService.test.ts+52
......@@ -172,6 +172,18 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
172172 Effect.void,
173173 );
174174
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 );
175187 const respondToRequest = vi.fn(
176188 (
177189 _threadId: ThreadId,
......@@ -250,6 +262,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
250262 },
251263 startSession,
252264 sendTurn,
265 ...(provider === CODEX_DRIVER ? { compactThread } : {}),
253266 interruptTurn,
254267 respondToRequest,
255268 respondToUserInput,
......@@ -286,6 +299,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
286299 updateSession,
287300 startSession,
288301 sendTurn,
302 compactThread,
289303 interruptTurn,
290304 respondToRequest,
291305 respondToUserInput,
......@@ -1034,6 +1048,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
10341048 });
10351049 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
10361050
1051 yield* advanceTestClock(10);
1052 yield* provider.compactThread(session.threadId);
1053 assert.deepEqual(routing.codex.compactThread.mock.calls, [[session.threadId, undefined]]);
1054
10371055 yield* provider.interruptTurn({ threadId: session.threadId });
10381056 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]);
10391057
......@@ -1097,6 +1115,40 @@ routing.layer("ProviderServiceLive routing", (it) => {
10971115 }),
10981116 );
10991117
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
11001152 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>
11011153 Effect.gen(function* () {
11021154 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+111-8
......@@ -10,6 +10,7 @@
1010 * @module ProviderServiceLive
1111 */
1212import {
13 EventId,
1314 ModelSelection,
1415 NonNegativeInt,
1516 ThreadId,
......@@ -28,6 +29,7 @@ import {
2829import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
2930import { causeErrorTag } from "@t3tools/shared/observability";
3031import * as DateTime from "effect/DateTime";
32import * as Deferred from "effect/Deferred";
3133import * as Effect from "effect/Effect";
3234import * as Layer from "effect/Layer";
3335import * as Option from "effect/Option";
......@@ -49,6 +51,7 @@ import {
4951 providerTurnMetricAttributes,
5052 withMetrics,
5153} from "../../observability/Metrics.ts";
54import { ProviderAdapterRequestError } from "../Errors.ts";
5255import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
5356import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
5457import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
......@@ -233,6 +236,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
233236 const revokeMcpCredential =
234237 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
235238 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 };
236250 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
237251 /**
238252 * Attach the `t3-code` MCP server to the session that is about to start.
......@@ -345,14 +359,58 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
345359 },
346360 event: ProviderRuntimeEvent,
347361 ): 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 });
356414
357415 // `subscribedAdapters` is our source-of-truth for "which instance adapters
358416 // are currently wired into the runtime event bus". It both tracks the set
......@@ -854,6 +912,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
854912 );
855913 });
856914
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
857959 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(
858960 function* (rawInput) {
859961 const input = yield* decodeInputOrValidationError({
......@@ -1249,6 +1351,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
12491351 return {
12501352 startSession,
12511353 sendTurn,
1354 compactThread,
12521355 interruptTurn,
12531356 respondToRequest,
12541357 respondToUserInput,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => {
164164 const providerService: ProviderServiceShape = {
165165 startSession: () => unsupported(),
166166 sendTurn: () => unsupported(),
167 compactThread: () => unsupported(),
167168 interruptTurn: () => unsupported(),
168169 respondToRequest: () => unsupported(),
169170 respondToUserInput: () => unsupported(),
apps/server/src/provider/Services/ProviderAdapter.ts+5
......@@ -68,6 +68,11 @@ export interface ProviderAdapterShape<TError> {
6868 input: ProviderSendTurnInput,
6969 ) => Effect.Effect<ProviderTurnStartResult, TError>;
7070
71 readonly compactThread?: (
72 threadId: ThreadId,
73 modelSelection?: ProviderSendTurnInput["modelSelection"],
74 ) => Effect.Effect<void, TError>;
75
7176 /**
7277 * Interrupt an active turn.
7378 */
apps/server/src/provider/Services/ProviderService.ts+5
......@@ -53,6 +53,11 @@ export interface ProviderServiceShape {
5353 input: ProviderSendTurnInput,
5454 ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>;
5555
56 readonly compactThread: (
57 threadId: ThreadId,
58 modelSelection?: ProviderSendTurnInput["modelSelection"],
59 ) => Effect.Effect<void, ProviderServiceError>;
60
5661 /**
5762 * Interrupt a running provider turn.
5863 */
apps/server/src/provider/providerSnapshot.ts+5
......@@ -22,6 +22,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000;
2222// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
2323export const AUTH_PROBE_TIMEOUT_MS = 10_000;
2424
25export const COMPACT_SLASH_COMMAND = {
26 name: "compact",
27 description: "Summarize the conversation and reduce context usage",
28} satisfies ServerProviderSlashCommand;
29
2530export interface CommandResult {
2631 readonly stdout: string;
2732 readonly stderr: string;
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
......@@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
5151 ({
5252 startSession: () => Effect.die("unused"),
5353 sendTurn: () => Effect.die("unused"),
54 compactThread: () => Effect.die("unused"),
5455 interruptTurn: () => Effect.die("unused"),
5556 respondToRequest: () => Effect.die("unused"),
5657 respondToUserInput: () => Effect.die("unused"),
apps/web/src/components/ChatView.tsx+21-19
......@@ -328,7 +328,7 @@ import {
328328import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
329329import { ComposerSurface } from "./chat/ComposerSurface";
330330import {
331 hasAvailableClaudeCompactionProvider,
331 hasAvailableCompactionProvider,
332332 hasDismissedResumeCompaction,
333333 shouldOfferResumeCompaction,
334334} from "./chat/ContextWindowMeter.logic";
......@@ -2944,13 +2944,23 @@ function ChatViewContent(props: ChatViewProps) {
29442944 activeThread?.modelSelection.instanceId ??
29452945 activeProject?.defaultModelSelection?.instanceId ??
29462946 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(
29482957 () =>
2949 hasAvailableClaudeCompactionProvider({
2958 hasAvailableCompactionProvider({
29502959 providers: applyProviderInstanceSettings(
29512960 deriveProviderInstanceEntries(providerStatuses),
29522961 settings,
29532962 ),
2963 driverKind: selectedProvider,
29542964 instanceId: activeProviderInstanceId,
29552965 lockedInstanceId: lockedProvider
29562966 ? (activeThread?.session?.providerInstanceId ??
......@@ -2964,18 +2974,10 @@ function ChatViewContent(props: ChatViewProps) {
29642974 activeThread?.session?.providerInstanceId,
29652975 lockedProvider,
29662976 providerStatuses,
2977 selectedProvider,
29672978 settings,
29682979 ],
29692980 );
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]);
29792981 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
29802982 useLocalStorage(
29812983 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
......@@ -5123,12 +5125,11 @@ function ChatViewContent(props: ChatViewProps) {
51235125 activeThread && activeContextWindow
51245126 ? `${activeThread.id}:${activeContextWindow.updatedAt}`
51255127 : null;
5126 const compactDisabled =
5128 const compactThreadUnavailable =
51275129 !activeThread ||
51285130 !activeProject ||
51295131 !isServerThread ||
5130 selectedProvider !== "claudeAgent" ||
5131 !compactionProviderAvailable ||
5132 !manualCompactionProviderAvailable ||
51325133 isWorking ||
51335134 threadDetailLoading ||
51345135 isPreparingWorktree ||
......@@ -5136,15 +5137,15 @@ function ChatViewContent(props: ChatViewProps) {
51365137 feedbackUploading ||
51375138 pendingApprovals.length > 0 ||
51385139 pendingUserInputs.length > 0 ||
5139 showPlanFollowUpPrompt ||
5140 composerHasUnsentContent;
5140 showPlanFollowUpPrompt;
5141 const compactDisabled = compactThreadUnavailable || composerHasUnsentContent;
51415142 const compactDisabledReason = compactDisabled
51425143 ? composerHasUnsentContent
51435144 ? "Send or clear your draft before compacting"
51445145 : !activeProject
51455146 ? "Choose a project before compacting"
5146 : !compactionProviderAvailable
5147 ? "Enable a Claude provider before compacting"
5147 : !manualCompactionProviderAvailable
5148 ? "Compaction is unavailable for this provider"
51485149 : "Compacting is unavailable right now"
51495150 : null;
51505151 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
......@@ -7528,6 +7529,7 @@ function ChatViewContent(props: ChatViewProps) {
75287529 }
75297530 activeThreadModelSelection={activeThread?.modelSelection}
75307531 activeContextWindow={activeContextWindow}
7532 compactThreadUnavailable={compactThreadUnavailable}
75317533 compactDisabled={compactDisabled}
75327534 compactDisabledReason={compactDisabledReason}
75337535 resolvedTheme={resolvedTheme}
apps/web/src/components/chat/ChatComposer.tsx+23-8
......@@ -163,7 +163,10 @@ import {
163163 renderProviderTraitsPicker,
164164} from "./composerProviderState";
165165import { ContextWindowMeter } from "./ContextWindowMeter";
166import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";
166import {
167 providerSupportsManualCompaction,
168 resolveContextWindowModelDisplayName,
169} from "./ContextWindowMeter.logic";
167170import {
168171 attachVideoThumbnail,
169172 buildExpandedImagePreview,
......@@ -691,6 +694,7 @@ export interface ChatComposerProps {
691694
692695 // Context window
693696 activeContextWindow: ContextWindowSnapshot | null;
697 compactThreadUnavailable: boolean;
694698 compactDisabled: boolean;
695699 compactDisabledReason: string | null;
696700
......@@ -787,6 +791,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
787791 activeProjectDefaultModelSelection,
788792 activeThreadModelSelection,
789793 activeContextWindow,
794 compactThreadUnavailable,
790795 compactDisabled,
791796 compactDisabledReason,
792797 resolvedTheme,
......@@ -1120,6 +1125,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11201125 () => selectedProviderEntry?.snapshot ?? null,
11211126 [selectedProviderEntry],
11221127 );
1128 const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry);
11231129 const selectedProviderSkills = selectedProviderStatus
11241130 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)
11251131 : [];
......@@ -1348,7 +1354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13481354 prompt,
13491355 ],
13501356 );
1351
13521357 // ------------------------------------------------------------------
13531358 // Derived: composer trigger / menu
13541359 // ------------------------------------------------------------------
......@@ -1360,6 +1365,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13601365 cwd: isPathTrigger ? gitCwd : null,
13611366 query: isPathTrigger ? pathTriggerQuery : null,
13621367 });
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;
13631378
13641379 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
13651380 if (!composerTrigger) return [];
......@@ -1428,8 +1443,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14281443 skill.description ??
14291444 (skill.scope ? `${skill.scope} skill` : ""),
14301445 }));
1446 const visibleProviderSlashCommandItems = providerSlashCommandItems.filter(
1447 (item) => item.command.name !== "compact" || compactSlashCommandAvailable,
1448 );
14311449 const slashCommandItems = slashCommandItemsForPromptPosition(
1432 [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems],
1450 [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems],
14331451 composerTrigger.rangeStart === 0,
14341452 );
14351453 return searchSlashCommandItems(slashCommandItems, query);
......@@ -1449,6 +1467,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14491467 }
14501468 return [];
14511469 }, [
1470 compactSlashCommandAvailable,
14521471 composerTrigger,
14531472 planModeUiEnabled,
14541473 selectedProvider,
......@@ -2298,7 +2317,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22982317 if (
22992318 compactDisabled ||
23002319 noProviderAvailable ||
2301 composerSendState.hasSendableContent ||
23022320 activePendingApproval !== null ||
23032321 pendingUserInputs.length > 0 ||
23042322 phase === "running" ||
......@@ -2336,7 +2354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23362354 activeThreadId,
23372355 compactDisabled,
23382356 composerDraftTarget,
2339 composerSendState.hasSendableContent,
23402357 isConnecting,
23412358 isSendBusy,
23422359 noProviderAvailable,
......@@ -4402,9 +4419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44024419 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
44034420 }
44044421 compactDisabledReason={resolvedCompactDisabledReason}
4405 {...(selectedProvider === "claudeAgent"
4406 ? { onCompactContext: compactThreadContext }
4407 : {})}
4422 {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})}
44084423 />
44094424 </div>
44104425 </div>
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+7-5
......@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33import { deriveProviderInstanceEntries } from "../../providerInstances";
44import {
55 formatContextWindowCompactionMessage,
6 hasAvailableClaudeCompactionProvider,
6 hasAvailableCompactionProvider,
77 hasDismissedResumeCompaction,
88 resolveContextWindowModelDisplayName,
99 shouldOfferResumeCompaction,
......@@ -25,12 +25,12 @@ function claudeProvider(input: {
2525 auth: { status: "authenticated" },
2626 checkedAt: "2026-08-24T12:00:00.000Z",
2727 models: [],
28 slashCommands: [],
28 slashCommands: [{ name: "compact", description: "" }],
2929 skills: [],
3030 };
3131}
3232
33describe("hasAvailableClaudeCompactionProvider", () => {
33describe("hasAvailableCompactionProvider", () => {
3434 const originalInstanceId = ProviderInstanceId.make("claude_original");
3535
3636 it("rejects a fallback in a different locked continuation group", () => {
......@@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
4747 ]);
4848
4949 expect(
50 hasAvailableClaudeCompactionProvider({
50 hasAvailableCompactionProvider({
5151 providers,
52 driverKind: ProviderDriverKind.make("claudeAgent"),
5253 instanceId: originalInstanceId,
5354 lockedInstanceId: originalInstanceId,
5455 }),
......@@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
6970 ]);
7071
7172 expect(
72 hasAvailableClaudeCompactionProvider({
73 hasAvailableCompactionProvider({
7374 providers,
75 driverKind: ProviderDriverKind.make("claudeAgent"),
7476 instanceId: originalInstanceId,
7577 lockedInstanceId: originalInstanceId,
7678 }),
apps/web/src/components/chat/ContextWindowMeter.logic.ts+16-10
......@@ -1,4 +1,4 @@
1import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
1import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
22import {
33 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
44 isClaudeResumeCompactionQuestion,
......@@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils
1212export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
1313export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
1414
15export function hasAvailableClaudeCompactionProvider(input: {
15export function providerSupportsManualCompaction(
16 provider: ProviderInstanceEntry | null | undefined,
17): boolean {
18 return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false;
19}
20
21export function hasAvailableCompactionProvider(input: {
1622 readonly providers: ReadonlyArray<ProviderInstanceEntry>;
23 readonly driverKind: ProviderDriverKind;
1724 readonly instanceId: ProviderInstanceId | null;
1825 readonly lockedInstanceId: ProviderInstanceId | null;
1926}): boolean {
20 const claudeProviders = input.providers.filter(
21 (provider) => provider.driverKind === "claudeAgent",
27 const driverProviders = input.providers.filter(
28 (provider) => provider.driverKind === input.driverKind,
2229 );
2330 const lockedContinuationGroupKey = input.lockedInstanceId
24 ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
31 ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
2532 ?.continuationGroupKey
2633 : undefined;
2734 const compatibleProviders = lockedContinuationGroupKey
28 ? claudeProviders.filter(
35 ? driverProviders.filter(
2936 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
3037 )
31 : claudeProviders;
38 : driverProviders;
3239
33 return (
34 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !==
35 undefined
40 return providerSupportsManualCompaction(
41 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined),
3642 );
3743}
3844
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-2
......@@ -919,7 +919,7 @@ describe("MessagesTimeline", () => {
919919 entry: {
920920 id: "work-1",
921921 createdAt: "2026-03-17T19:12:28.000Z",
922 label: "Context compacted",
922 label: "Compacted context 899K → 19K tokens",
923923 tone: "info",
924924 },
925925 },
......@@ -927,7 +927,7 @@ describe("MessagesTimeline", () => {
927927 />,
928928 );
929929
930 expect(markup).toContain("Context compacted");
930 expect(markup).toContain("Compacted context 899K → 19K tokens");
931931 });
932932
933933 it("summarizes changed files in one line", () => {
docs/user/composer.md+2
......@@ -196,6 +196,8 @@ such as System, Personal, Project, or App.
196196On mobile, these menus are available on the **New task** screen before you start a thread. They
197197use the skills and commands from the selected environment and provider.
198198
199In 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
199201By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in
200202slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the
201203same `$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;
298298
299299const ThreadStateChangedPayload = Schema.Struct({
300300 state: RuntimeThreadState,
301 beforeTokens: Schema.optional(NonNegativeInt),
302 afterTokens: Schema.optional(NonNegativeInt),
301303 detail: Schema.optional(Schema.Unknown),
302304});
303305export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type;