authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 18:58:07-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 18:58:07-04:00
log63f334baf3432c4404ddfc9e71d33bf16575f0ed
treea868ddf8cc912c2ae8921964e29f219103884f95
parent064392ffc7fc58fcc3018fd2ba2df33fbb21f1de
signature Signed by PGP key B5690EEEBB952194

Revert "feat(providers): add context compaction across harnesses" (#9284)


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({
127127 // Codex feedback uploads an existing thread's session and logs.
128128 if (
129129 !hasThread &&
130 (command.name === "compact" ||
131 (selectedProviderStatus?.driver === "codex" && command.name === "feedback"))
130 selectedProviderStatus?.driver === "codex" &&
131 command.name === "feedback"
132132 ) {
133133 continue;
134134 }
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts-1
......@@ -110,7 +110,6 @@ const startupDependencies = Layer.mergeAll(
110110 Layer.succeed(ProviderService.ProviderService, {
111111 startSession: () => Effect.die("unused"),
112112 sendTurn: () => Effect.die("unused"),
113 compactThread: () => Effect.die("unused"),
114113 interruptTurn: () => Effect.die("unused"),
115114 respondToRequest: () => Effect.die("unused"),
116115 respondToUserInput: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts-1
......@@ -107,7 +107,6 @@ function createProviderServiceHarness(
107107 const service: ProviderServiceShape = {
108108 startSession: () => unsupported(),
109109 sendTurn: () => unsupported(),
110 compactThread: () => unsupported(),
111110 interruptTurn: () => unsupported(),
112111 respondToRequest: () => unsupported(),
113112 respondToUserInput: () => unsupported(),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts-15
......@@ -1275,21 +1275,6 @@ 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
12931278 case "thread.session-set": {
12941279 const turnId = event.payload.session.activeTurnId;
12951280 if (turnId === null || event.payload.session.status !== "running") {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts-24
......@@ -254,7 +254,6 @@ describe("ProviderCommandReactor", () => {
254254 turnId: asTurnId("turn-1"),
255255 }),
256256 );
257 const compactThread = vi.fn((_: ThreadId) => Effect.void);
258257 const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void);
259258 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
260259 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
......@@ -340,7 +339,6 @@ describe("ProviderCommandReactor", () => {
340339 const service: ProviderServiceShape = {
341340 startSession: startSession as ProviderServiceShape["startSession"],
342341 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
343 compactThread,
344342 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
345343 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
346344 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
......@@ -535,7 +533,6 @@ describe("ProviderCommandReactor", () => {
535533 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
536534 startSession,
537535 sendTurn,
538 compactThread,
539536 interruptTurn,
540537 respondToRequest,
541538 respondToUserInput,
......@@ -635,27 +632,6 @@ describe("ProviderCommandReactor", () => {
635632 }),
636633 );
637634
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 );
659635 effectIt.effect("projects starting before a slow provider session finishes", () =>
660636 Effect.gen(function* () {
661637 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";
3030
3131import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
3232import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
33import {
34 ProviderAdapterRequestError,
35 ProviderAdapterValidationError,
36} from "../../provider/Errors.ts";
33import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
3734import type { ProviderServiceError } from "../../provider/Errors.ts";
3835import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
3936import { ProviderService } from "../../provider/Services/ProviderService.ts";
......@@ -53,7 +50,6 @@ import {
5350import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
5451import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
5552const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
56const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
5753const isProviderDriverKind = Schema.is(ProviderDriverKind);
5854
5955type ProviderIntentEvent = Extract<
......@@ -76,10 +72,6 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined
7672 return normalized && normalized.length > 0 ? normalized : undefined;
7773}
7874
79const isCompactCommandMessage = (message: ThreadTitleMessage): boolean =>
80 message.role === "user" &&
81 (message.attachments?.length ?? 0) === 0 &&
82 message.text.trim().toLowerCase() === "/compact";
8375function mapProviderSessionStatusToOrchestrationStatus(
8476 status: "connecting" | "ready" | "running" | "error" | "closed",
8577): OrchestrationSession["status"] {
......@@ -340,7 +332,6 @@ const make = Effect.gen(function* () {
340332 );
341333
342334 const threadModelSelections = new Map<string, ModelSelection>();
343 const compactingThreadIds = new Set<ThreadId>();
344335
345336 const appendProviderFailureActivity = (input: {
346337 readonly threadId: ThreadId;
......@@ -384,11 +375,11 @@ const make = Effect.gen(function* () {
384375
385376 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {
386377 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;
392383 }
393384 return Cause.pretty(cause);
394385 };
......@@ -1146,6 +1137,7 @@ const make = Effect.gen(function* () {
11461137 if (!thread) {
11471138 return;
11481139 }
1140
11491141 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);
11501142 if (!message || message.role !== "user") {
11511143 yield* appendProviderFailureActivity({
......@@ -1155,28 +1147,15 @@ const make = Effect.gen(function* () {
11551147 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,
11561148 turnId: null,
11571149 createdAt: event.payload.createdAt,
1158 requestId: event.payload.messageId,
11591150 });
11601151 return;
11611152 }
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 });
11721153
11731154 yield* ensureThreadWorktree(thread);
11741155
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) {
11801159 const project = yield* resolveProject(thread.projectId);
11811160 const generationCwd =
11821161 resolveThreadWorkspaceCwd({
......@@ -1215,7 +1194,16 @@ const make = Effect.gen(function* () {
12151194 detail,
12161195 createdAt: event.payload.createdAt,
12171196 }).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 ),
12191207 Effect.asVoid,
12201208 );
12211209 };
......@@ -1232,69 +1220,6 @@ const make = Effect.gen(function* () {
12321220 ),
12331221 );
12341222
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 }
12981223 const sendTurnRequest = yield* buildSendTurnRequestForThread({
12991224 threadId: event.payload.threadId,
13001225 messageText: message.text,
......@@ -1315,7 +1240,7 @@ const make = Effect.gen(function* () {
13151240
13161241 yield* providerService
13171242 .sendTurn(sendTurnRequest.value)
1318 .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
1243 .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
13191244 });
13201245
13211246 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1-4
......@@ -105,7 +105,6 @@ function createProviderServiceHarness() {
105105 const service: ProviderServiceShape = {
106106 startSession: () => unsupported(),
107107 sendTurn: () => unsupported(),
108 compactThread: () => unsupported(),
109108 interruptTurn: () => unsupported(),
110109 respondToRequest: () => unsupported(),
111110 respondToUserInput: () => unsupported(),
......@@ -3222,8 +3221,6 @@ describe("ProviderRuntimeIngestion", () => {
32223221 turnId: asTurnId("turn-1"),
32233222 payload: {
32243223 state: "compacted",
3225 beforeTokens: 899_000,
3226 afterTokens: 19_000,
32273224 detail: { source: "provider" },
32283225 },
32293226 });
......@@ -3237,7 +3234,7 @@ describe("ProviderRuntimeIngestion", () => {
32373234 const activity = thread.activities.find(
32383235 (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction",
32393236 );
3240 expect(activity?.summary).toBe("Compacted context 899K → 19K tokens");
3237 expect(activity?.summary).toBe("Context compacted");
32413238 expect(activity?.tone).toBe("info");
32423239 });
32433240
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1-10
......@@ -28,7 +28,6 @@ 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";
3231
3332import { ProviderService } from "../../provider/Services/ProviderService.ts";
3433import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
......@@ -754,23 +753,15 @@ export function runtimeEventToActivities(
754753 return [];
755754 }
756755
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";
763756 return [
764757 {
765758 id: event.eventId,
766759 createdAt: event.createdAt,
767760 tone: "info",
768761 kind: "context-compaction",
769 summary,
762 summary: "Context compacted",
770763 payload: {
771764 state: event.payload.state,
772 ...(beforeTokens !== undefined ? { beforeTokens } : {}),
773 ...(afterTokens !== undefined ? { afterTokens } : {}),
774765 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
775766 },
776767 turnId: toTurnId(event.turnId) ?? null,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+6-7
......@@ -729,7 +729,12 @@ describe("ClaudeAdapterLive", () => {
729729 runtimeMode: "full-access",
730730 });
731731
732 yield* adapter.compactThread!(session.threadId, modelSelection);
732 yield* adapter.sendTurn({
733 threadId: session.threadId,
734 input: "/compact",
735 attachments: [],
736 modelSelection,
737 });
733738
734739 const promptText = yield* Effect.promise(() =>
735740 readFirstPromptText(harness.getLastCreateQueryInput()),
......@@ -2227,12 +2232,6 @@ describe("ClaudeAdapterLive", () => {
22272232 } as unknown as SDKMessage);
22282233
22292234 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);
22362235 const finalUsageEvent = runtimeEvents.findLast(
22372236 (event) => event.type === "thread.token-usage.updated",
22382237 );
apps/server/src/provider/Layers/ClaudeAdapter.ts+13-28
......@@ -3198,36 +3198,32 @@ 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 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 },
32103217 );
3211 yield* emitThreadTokenUsage(context, compactedUsage, {
3212 rawMethod: "claude/system/compact_boundary",
3213 rawPayload: message,
3214 });
32153218 yield* offerRuntimeEvent({
32163219 ...base,
32173220 type: "thread.state.changed",
32183221 payload: {
32193222 state: "compacted",
3220 ...(compactedUsage?.lastUsedTokens !== undefined
3221 ? { beforeTokens: compactedUsage.lastUsedTokens }
3222 : {}),
3223 ...(compactedUsage?.usedTokens !== undefined
3224 ? { afterTokens: compactedUsage.usedTokens }
3225 : {}),
32263223 detail: message,
32273224 },
32283225 });
32293226 return;
3230 }
32313227 case "hook_started":
32323228 yield* offerRuntimeEvent({
32333229 ...base,
......@@ -3786,7 +3782,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37863782
37873783 // Same reason as the approvals above: a request nobody can answer any more
37883784 // 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()]) {
37903786 yield* pending.cancel;
37913787 }
37923788
......@@ -4694,16 +4690,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
46944690 };
46954691 });
46964692
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
47074693 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
47084694 function* (threadId, _turnId) {
47094695 const context = yield* requireSession(threadId);
......@@ -4818,7 +4804,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
48184804 },
48194805 startSession,
48204806 sendTurn,
4821 compactThread,
48224807 interruptTurn,
48234808 readThread,
48244809 rollbackThread,
apps/server/src/provider/Layers/ClaudeProvider.ts+7-2
......@@ -22,7 +22,6 @@ import {
2222
2323import {
2424 buildServerProvider,
25 COMPACT_SLASH_COMMAND,
2625 DEFAULT_TIMEOUT_MS,
2726 isCommandMissingCause,
2827 parseGenericCliVersion,
......@@ -504,7 +503,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
504503 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
505504 : undefined;
506505 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 ];
508513 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
509514
510515 if (!capabilities) {
apps/server/src/provider/Layers/CodexAdapter.test.ts-43
......@@ -84,8 +84,6 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
8484 }),
8585 );
8686
87 public readonly compactThread = Effect.void;
88
8987 public readonly interruptTurnImpl = vi.fn(
9088 (_turnId?: TurnId): Promise<void> => Promise.resolve(undefined),
9189 );
......@@ -338,47 +336,6 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
338336 }),
339337 );
340338
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
382339 it.effect("uploads feedback for the active Codex thread", () =>
383340 Effect.gen(function* () {
384341 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+1-23
......@@ -11,7 +11,6 @@ import {
1111 type CanonicalItemType,
1212 type CanonicalRequestType,
1313 type CodexSettings,
14 EventId,
1514 ProviderDriverKind,
1615 type ProviderEvent,
1716 ProviderInstanceId,
......@@ -1152,18 +1151,7 @@ function mapToRuntimeEvents(
11521151 ];
11531152 }
11541153 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] : [];
11671155 }
11681156
11691157 if (
......@@ -1891,15 +1879,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
18911879 ),
18921880 );
18931881
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
19031882 const readThread: CodexAdapterShape["readThread"] = (threadId) =>
19041883 requireSession(threadId).pipe(
19051884 Effect.flatMap((session) => session.runtime.readThread),
......@@ -2035,7 +2014,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
20352014 },
20362015 startSession,
20372016 sendTurn,
2038 compactThread,
20392017 interruptTurn,
20402018 readThread,
20412019 rollbackThread,
apps/server/src/provider/Layers/CodexProvider.ts-2
......@@ -30,7 +30,6 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts
3030import {
3131 AUTH_PROBE_TIMEOUT_MS,
3232 buildServerProvider,
33 COMPACT_SLASH_COMMAND,
3433 type ServerProviderDraft,
3534} from "../providerSnapshot.ts";
3635import { expandHomePath } from "../../pathExpansion.ts";
......@@ -649,7 +648,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
649648 models: snapshot.models,
650649 skills: snapshot.skills,
651650 slashCommands: [
652 COMPACT_SLASH_COMMAND,
653651 {
654652 name: "feedback",
655653 description: "Send this thread and Codex logs to OpenAI",
apps/server/src/provider/Layers/CodexSessionRuntime.ts-5
......@@ -196,7 +196,6 @@ export interface CodexSessionRuntimeShape {
196196 readonly sendTurn: (
197197 input: CodexSessionRuntimeSendTurnInput,
198198 ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>;
199 readonly compactThread: Effect.Effect<void, CodexSessionRuntimeError>;
200199 readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>;
201200 readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;
202201 readonly rollbackThread: (
......@@ -2294,10 +2293,6 @@ export const makeCodexSessionRuntime = (
22942293 return {
22952294 start,
22962295 getSession: Ref.get(sessionRef),
2297 compactThread: Effect.gen(function* () {
2298 const providerThreadId = yield* readProviderThreadId;
2299 yield* client.request("thread/compact/start", { threadId: providerThreadId });
2300 }),
23012296 sendTurn: (input) =>
23022297 Effect.gen(function* () {
23032298 const providerThreadId = yield* readProviderThreadId;
apps/server/src/provider/Layers/CursorProvider.ts-2
......@@ -34,7 +34,6 @@ import {
3434 buildBooleanOptionDescriptor,
3535 buildSelectOptionDescriptor,
3636 buildServerProvider,
37 COMPACT_SLASH_COMMAND,
3837 collectStreamAsString,
3938 isCommandMissingCause,
4039 providerModelsFromSettings,
......@@ -640,7 +639,6 @@ export function buildCursorProviderSnapshot(input: {
640639 input.cursorSettings.customModels,
641640 EMPTY_CAPABILITIES,
642641 ),
643 slashCommands: [COMPACT_SLASH_COMMAND],
644642 probe: {
645643 installed: true,
646644 version: input.parsed.version,
apps/server/src/provider/Layers/GrokProvider.ts-2
......@@ -21,7 +21,6 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
2121import {
2222 AUTH_PROBE_TIMEOUT_MS,
2323 buildServerProvider,
24 COMPACT_SLASH_COMMAND,
2524 isCommandMissingCause,
2625 parseGenericCliVersion,
2726 providerModelsFromSettings,
......@@ -497,7 +496,6 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
497496 checkedAt,
498497 models,
499498 skills,
500 slashCommands: [COMPACT_SLASH_COMMAND],
501499 probe: {
502500 installed: true,
503501 version,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts-39
......@@ -79,7 +79,6 @@ const runtimeMock = {
7979 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
8080 messageFailures: 0,
8181 promptCalls: [] as Array<unknown>,
82 summarizeCalls: [] as Array<unknown>,
8382 promptAsyncError: null as Error | null,
8483 promptAsyncImplementation: null as (() => Promise<void>) | null,
8584 autoPromptEcho: true,
......@@ -130,7 +129,6 @@ const runtimeMock = {
130129 this.state.messageCalls.length = 0;
131130 this.state.messageFailures = 0;
132131 this.state.promptCalls.length = 0;
133 this.state.summarizeCalls.length = 0;
134132 this.state.promptAsyncError = null;
135133 this.state.promptAsyncImplementation = null;
136134 this.state.autoPromptEcho = true;
......@@ -315,10 +313,6 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
315313 });
316314 }
317315 },
318 summarize: async (input: unknown) => {
319 runtimeMock.state.summarizeCalls.push(input);
320 return { data: true };
321 },
322316 messages: async () => ({ data: runtimeMock.state.messages }),
323317 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
324318 runtimeMock.state.messageCalls.push({ sessionID, messageID });
......@@ -955,39 +949,6 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
955949 }),
956950 );
957951
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 );
991952 it.effect("falls back to a fresh session when the persisted session is gone", () =>
992953 Effect.gen(function* () {
993954 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts-81
......@@ -2014,21 +2014,6 @@ 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 }
20322017
20332018 case "message.updated": {
20342019 const promptAdmission = context.promptAdmission;
......@@ -2999,71 +2984,6 @@ export function makeOpenCodeAdapter(
29992984 );
30002985 });
30012986
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 });
30672987 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
30682988 function* (threadId, turnId) {
30692989 const context = yield* ensureSessionContext(sessions, threadId);
......@@ -3348,7 +3268,6 @@ export function makeOpenCodeAdapter(
33483268 },
33493269 startSession,
33503270 sendTurn,
3351 compactThread,
33523271 interruptTurn,
33533272 respondToRequest,
33543273 respondToUserInput,
apps/server/src/provider/Layers/OpenCodeProvider.ts-2
......@@ -13,7 +13,6 @@ import { createModelCapabilities } from "@t3tools/shared/model";
1313import { compareSemverVersions } from "@t3tools/shared/semver";
1414import {
1515 buildServerProvider,
16 COMPACT_SLASH_COMMAND,
1716 nonEmptyTrimmed,
1817 parseGenericCliVersion,
1918 providerModelsFromSettings,
......@@ -496,7 +495,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
496495 checkedAt,
497496 models,
498497 skills,
499 slashCommands: [COMPACT_SLASH_COMMAND],
500498 probe: {
501499 installed: true,
502500 version,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+10-4
......@@ -48,7 +48,6 @@ 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";
5251import type { ProviderInstance } from "../ProviderDriver.ts";
5352import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
5453import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
......@@ -385,7 +384,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
385384 shortDescription: "Debug failing GitHub Actions checks",
386385 },
387386 ]);
388 assert.deepStrictEqual(status.slashCommands.slice(1), [
387 assert.deepStrictEqual(status.slashCommands, [
389388 {
390389 name: "feedback",
391390 description: "Send this thread and Codex logs to OpenAI",
......@@ -2440,7 +2439,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
24402439 }),
24412440 );
24422441
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 },
24442447 {
24452448 name: "review",
24462449 description: "Review a pull request",
......@@ -2484,7 +2487,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
24842487 );
24852488
24862489 assert.deepStrictEqual(status.slashCommands, [
2487 COMPACT_SLASH_COMMAND,
2490 {
2491 name: "compact",
2492 description: "Summarize the conversation and reduce context usage",
2493 },
24882494 {
24892495 name: "ui",
24902496 description: "Explore and refine UI",
apps/server/src/provider/Layers/ProviderService.test.ts-52
......@@ -172,18 +172,6 @@ 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 );
187175 const respondToRequest = vi.fn(
188176 (
189177 _threadId: ThreadId,
......@@ -262,7 +250,6 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
262250 },
263251 startSession,
264252 sendTurn,
265 ...(provider === CODEX_DRIVER ? { compactThread } : {}),
266253 interruptTurn,
267254 respondToRequest,
268255 respondToUserInput,
......@@ -299,7 +286,6 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
299286 updateSession,
300287 startSession,
301288 sendTurn,
302 compactThread,
303289 interruptTurn,
304290 respondToRequest,
305291 respondToUserInput,
......@@ -1048,10 +1034,6 @@ routing.layer("ProviderServiceLive routing", (it) => {
10481034 });
10491035 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
10501036
1051 yield* advanceTestClock(10);
1052 yield* provider.compactThread(session.threadId);
1053 assert.deepEqual(routing.codex.compactThread.mock.calls, [[session.threadId, undefined]]);
1054
10551037 yield* provider.interruptTurn({ threadId: session.threadId });
10561038 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]);
10571039
......@@ -1115,40 +1097,6 @@ routing.layer("ProviderServiceLive routing", (it) => {
11151097 }),
11161098 );
11171099
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
11521100 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>
11531101 Effect.gen(function* () {
11541102 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+8-111
......@@ -10,7 +10,6 @@
1010 * @module ProviderServiceLive
1111 */
1212import {
13 EventId,
1413 ModelSelection,
1514 NonNegativeInt,
1615 ThreadId,
......@@ -29,7 +28,6 @@ import {
2928import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
3029import { causeErrorTag } from "@t3tools/shared/observability";
3130import * as DateTime from "effect/DateTime";
32import * as Deferred from "effect/Deferred";
3331import * as Effect from "effect/Effect";
3432import * as Layer from "effect/Layer";
3533import * as Option from "effect/Option";
......@@ -51,7 +49,6 @@ import {
5149 providerTurnMetricAttributes,
5250 withMetrics,
5351} from "../../observability/Metrics.ts";
54import { ProviderAdapterRequestError } from "../Errors.ts";
5552import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
5653import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
5754import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
......@@ -236,17 +233,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
236233 const revokeMcpCredential =
237234 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
238235 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 };
250236 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
251237 /**
252238 * Attach the `t3-code` MCP server to the session that is about to start.
......@@ -359,58 +345,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
359345 },
360346 event: ProviderRuntimeEvent,
361347 ): 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 );
414356
415357 // `subscribedAdapters` is our source-of-truth for "which instance adapters
416358 // are currently wired into the runtime event bus". It both tracks the set
......@@ -912,50 +854,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
912854 );
913855 });
914856
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
959857 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(
960858 function* (rawInput) {
961859 const input = yield* decodeInputOrValidationError({
......@@ -1351,7 +1249,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
13511249 return {
13521250 startSession,
13531251 sendTurn,
1354 compactThread,
13551252 interruptTurn,
13561253 respondToRequest,
13571254 respondToUserInput,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts-1
......@@ -164,7 +164,6 @@ describe("ProviderSessionReaper", () => {
164164 const providerService: ProviderServiceShape = {
165165 startSession: () => unsupported(),
166166 sendTurn: () => unsupported(),
167 compactThread: () => unsupported(),
168167 interruptTurn: () => unsupported(),
169168 respondToRequest: () => unsupported(),
170169 respondToUserInput: () => unsupported(),
apps/server/src/provider/Services/ProviderAdapter.ts-5
......@@ -68,11 +68,6 @@ 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
7671 /**
7772 * Interrupt an active turn.
7873 */
apps/server/src/provider/Services/ProviderService.ts-5
......@@ -53,11 +53,6 @@ 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
6156 /**
6257 * Interrupt a running provider turn.
6358 */
apps/server/src/provider/providerSnapshot.ts-5
......@@ -22,11 +22,6 @@ 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
3025export interface CommandResult {
3126 readonly stdout: string;
3227 readonly stderr: string;
apps/server/src/serverRuntimeStartup.reconcile.test.ts-1
......@@ -51,7 +51,6 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
5151 ({
5252 startSession: () => Effect.die("unused"),
5353 sendTurn: () => Effect.die("unused"),
54 compactThread: () => Effect.die("unused"),
5554 interruptTurn: () => Effect.die("unused"),
5655 respondToRequest: () => Effect.die("unused"),
5756 respondToUserInput: () => Effect.die("unused"),
apps/web/src/components/ChatView.tsx+19-21
......@@ -328,7 +328,7 @@ import {
328328import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
329329import { ComposerSurface } from "./chat/ComposerSurface";
330330import {
331 hasAvailableCompactionProvider,
331 hasAvailableClaudeCompactionProvider,
332332 hasDismissedResumeCompaction,
333333 shouldOfferResumeCompaction,
334334} from "./chat/ContextWindowMeter.logic";
......@@ -2947,23 +2947,13 @@ function ChatViewContent(props: ChatViewProps) {
29472947 activeThread?.modelSelection.instanceId ??
29482948 activeProject?.defaultModelSelection?.instanceId ??
29492949 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(
29602951 () =>
2961 hasAvailableCompactionProvider({
2952 hasAvailableClaudeCompactionProvider({
29622953 providers: applyProviderInstanceSettings(
29632954 deriveProviderInstanceEntries(providerStatuses),
29642955 settings,
29652956 ),
2966 driverKind: selectedProvider,
29672957 instanceId: activeProviderInstanceId,
29682958 lockedInstanceId: lockedProvider
29692959 ? (activeThread?.session?.providerInstanceId ??
......@@ -2977,10 +2967,18 @@ function ChatViewContent(props: ChatViewProps) {
29772967 activeThread?.session?.providerInstanceId,
29782968 lockedProvider,
29792969 providerStatuses,
2980 selectedProvider,
29812970 settings,
29822971 ],
29832972 );
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]);
29842982 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
29852983 useLocalStorage(
29862984 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
......@@ -5246,11 +5244,12 @@ function ChatViewContent(props: ChatViewProps) {
52465244 activeThread && activeContextWindow
52475245 ? `${activeThread.id}:${activeContextWindow.updatedAt}`
52485246 : null;
5249 const compactThreadUnavailable =
5247 const compactDisabled =
52505248 !activeThread ||
52515249 !activeProject ||
52525250 !isServerThread ||
5253 !manualCompactionProviderAvailable ||
5251 selectedProvider !== "claudeAgent" ||
5252 !compactionProviderAvailable ||
52545253 isWorking ||
52555254 threadDetailLoading ||
52565255 isPreparingWorktree ||
......@@ -5258,15 +5257,15 @@ function ChatViewContent(props: ChatViewProps) {
52585257 feedbackUploading ||
52595258 pendingApprovals.length > 0 ||
52605259 pendingUserInputs.length > 0 ||
5261 showPlanFollowUpPrompt;
5262 const compactDisabled = compactThreadUnavailable || composerHasUnsentContent;
5260 showPlanFollowUpPrompt ||
5261 composerHasUnsentContent;
52635262 const compactDisabledReason = compactDisabled
52645263 ? composerHasUnsentContent
52655264 ? "Send or clear your draft before compacting"
52665265 : !activeProject
52675266 ? "Choose a project before compacting"
5268 : !manualCompactionProviderAvailable
5269 ? "Compaction is unavailable for this provider"
5267 : !compactionProviderAvailable
5268 ? "Enable a Claude provider before compacting"
52705269 : "Compacting is unavailable right now"
52715270 : null;
52725271 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
......@@ -7650,7 +7649,6 @@ function ChatViewContent(props: ChatViewProps) {
76507649 }
76517650 activeThreadModelSelection={activeThread?.modelSelection}
76527651 activeContextWindow={activeContextWindow}
7653 compactThreadUnavailable={compactThreadUnavailable}
76547652 compactDisabled={compactDisabled}
76557653 compactDisabledReason={compactDisabledReason}
76567654 resolvedTheme={resolvedTheme}
apps/web/src/components/chat/ChatComposer.tsx+8-23
......@@ -163,10 +163,7 @@ import {
163163 renderProviderTraitsPicker,
164164} from "./composerProviderState";
165165import { ContextWindowMeter } from "./ContextWindowMeter";
166import {
167 providerSupportsManualCompaction,
168 resolveContextWindowModelDisplayName,
169} from "./ContextWindowMeter.logic";
166import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";
170167import {
171168 attachVideoThumbnail,
172169 buildExpandedImagePreview,
......@@ -694,7 +691,6 @@ export interface ChatComposerProps {
694691
695692 // Context window
696693 activeContextWindow: ContextWindowSnapshot | null;
697 compactThreadUnavailable: boolean;
698694 compactDisabled: boolean;
699695 compactDisabledReason: string | null;
700696
......@@ -791,7 +787,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
791787 activeProjectDefaultModelSelection,
792788 activeThreadModelSelection,
793789 activeContextWindow,
794 compactThreadUnavailable,
795790 compactDisabled,
796791 compactDisabledReason,
797792 resolvedTheme,
......@@ -1125,7 +1120,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11251120 () => selectedProviderEntry?.snapshot ?? null,
11261121 [selectedProviderEntry],
11271122 );
1128 const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry);
11291123 const selectedProviderSkills = selectedProviderStatus
11301124 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)
11311125 : [];
......@@ -1354,6 +1348,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13541348 prompt,
13551349 ],
13561350 );
1351
13571352 // ------------------------------------------------------------------
13581353 // Derived: composer trigger / menu
13591354 // ------------------------------------------------------------------
......@@ -1365,16 +1360,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13651360 cwd: isPathTrigger ? gitCwd : null,
13661361 query: isPathTrigger ? pathTriggerQuery : null,
13671362 });
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;
13781363
13791364 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
13801365 if (!composerTrigger) return [];
......@@ -1443,11 +1428,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14431428 skill.description ??
14441429 (skill.scope ? `${skill.scope} skill` : ""),
14451430 }));
1446 const visibleProviderSlashCommandItems = providerSlashCommandItems.filter(
1447 (item) => item.command.name !== "compact" || compactSlashCommandAvailable,
1448 );
14491431 const slashCommandItems = slashCommandItemsForPromptPosition(
1450 [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems],
1432 [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems],
14511433 composerTrigger.rangeStart === 0,
14521434 );
14531435 return searchSlashCommandItems(slashCommandItems, query);
......@@ -1467,7 +1449,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14671449 }
14681450 return [];
14691451 }, [
1470 compactSlashCommandAvailable,
14711452 composerTrigger,
14721453 planModeUiEnabled,
14731454 selectedProvider,
......@@ -2317,6 +2298,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23172298 if (
23182299 compactDisabled ||
23192300 noProviderAvailable ||
2301 composerSendState.hasSendableContent ||
23202302 activePendingApproval !== null ||
23212303 pendingUserInputs.length > 0 ||
23222304 phase === "running" ||
......@@ -2354,6 +2336,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23542336 activeThreadId,
23552337 compactDisabled,
23562338 composerDraftTarget,
2339 composerSendState.hasSendableContent,
23572340 isConnecting,
23582341 isSendBusy,
23592342 noProviderAvailable,
......@@ -4419,7 +4402,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44194402 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
44204403 }
44214404 compactDisabledReason={resolvedCompactDisabledReason}
4422 {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})}
4405 {...(selectedProvider === "claudeAgent"
4406 ? { onCompactContext: compactThreadContext }
4407 : {})}
44234408 />
44244409 </div>
44254410 </div>
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+5-7
......@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33import { deriveProviderInstanceEntries } from "../../providerInstances";
44import {
55 formatContextWindowCompactionMessage,
6 hasAvailableCompactionProvider,
6 hasAvailableClaudeCompactionProvider,
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: [{ name: "compact", description: "" }],
28 slashCommands: [],
2929 skills: [],
3030 };
3131}
3232
33describe("hasAvailableCompactionProvider", () => {
33describe("hasAvailableClaudeCompactionProvider", () => {
3434 const originalInstanceId = ProviderInstanceId.make("claude_original");
3535
3636 it("rejects a fallback in a different locked continuation group", () => {
......@@ -47,9 +47,8 @@ describe("hasAvailableCompactionProvider", () => {
4747 ]);
4848
4949 expect(
50 hasAvailableCompactionProvider({
50 hasAvailableClaudeCompactionProvider({
5151 providers,
52 driverKind: ProviderDriverKind.make("claudeAgent"),
5352 instanceId: originalInstanceId,
5453 lockedInstanceId: originalInstanceId,
5554 }),
......@@ -70,9 +69,8 @@ describe("hasAvailableCompactionProvider", () => {
7069 ]);
7170
7271 expect(
73 hasAvailableCompactionProvider({
72 hasAvailableClaudeCompactionProvider({
7473 providers,
75 driverKind: ProviderDriverKind.make("claudeAgent"),
7674 instanceId: originalInstanceId,
7775 lockedInstanceId: originalInstanceId,
7876 }),
apps/web/src/components/chat/ContextWindowMeter.logic.ts+10-16
......@@ -1,4 +1,4 @@
1import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
1import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
22import {
33 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
44 isClaudeResumeCompactionQuestion,
......@@ -12,33 +12,27 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils
1212export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
1313export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
1414
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: {
15export function hasAvailableClaudeCompactionProvider(input: {
2216 readonly providers: ReadonlyArray<ProviderInstanceEntry>;
23 readonly driverKind: ProviderDriverKind;
2417 readonly instanceId: ProviderInstanceId | null;
2518 readonly lockedInstanceId: ProviderInstanceId | null;
2619}): boolean {
27 const driverProviders = input.providers.filter(
28 (provider) => provider.driverKind === input.driverKind,
20 const claudeProviders = input.providers.filter(
21 (provider) => provider.driverKind === "claudeAgent",
2922 );
3023 const lockedContinuationGroupKey = input.lockedInstanceId
31 ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
24 ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
3225 ?.continuationGroupKey
3326 : undefined;
3427 const compatibleProviders = lockedContinuationGroupKey
35 ? driverProviders.filter(
28 ? claudeProviders.filter(
3629 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
3730 )
38 : driverProviders;
31 : claudeProviders;
3932
40 return providerSupportsManualCompaction(
41 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined),
33 return (
34 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !==
35 undefined
4236 );
4337}
4438
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: "Compacted context 899K → 19K tokens",
922 label: "Context compacted",
923923 tone: "info",
924924 },
925925 },
......@@ -927,7 +927,7 @@ describe("MessagesTimeline", () => {
927927 />,
928928 );
929929
930 expect(markup).toContain("Compacted context 899K → 19K tokens");
930 expect(markup).toContain("Context compacted");
931931 });
932932
933933 it("summarizes changed files in one line", () => {
docs/user/composer.md-2
......@@ -196,8 +196,6 @@ 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
201199By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in
202200slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the
203201same `$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;
298298
299299const ThreadStateChangedPayload = Schema.Struct({
300300 state: RuntimeThreadState,
301 beforeTokens: Schema.optional(NonNegativeInt),
302 afterTokens: Schema.optional(NonNegativeInt),
303301 detail: Schema.optional(Schema.Unknown),
304302});
305303export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type;