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({...@@ -127,8 +127,8 @@ export function useComposerCommandMenu({
127 // Codex feedback uploads an existing thread's session and logs.127 // Codex feedback uploads an existing thread's session and logs.
128 if (128 if (
129 !hasThread &&129 !hasThread &&
130 selectedProviderStatus?.driver === "codex" &&130 (command.name === "compact" ||
131 command.name === "feedback"131 (selectedProviderStatus?.driver === "codex" && command.name === "feedback"))
132 ) {132 ) {
133 continue;133 continue;
134 }134 }
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1
...@@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll(...@@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll(
110 Layer.succeed(ProviderService.ProviderService, {110 Layer.succeed(ProviderService.ProviderService, {
111 startSession: () => Effect.die("unused"),111 startSession: () => Effect.die("unused"),
112 sendTurn: () => Effect.die("unused"),112 sendTurn: () => Effect.die("unused"),
113 compactThread: () => Effect.die("unused"),
113 interruptTurn: () => Effect.die("unused"),114 interruptTurn: () => Effect.die("unused"),
114 respondToRequest: () => Effect.die("unused"),115 respondToRequest: () => Effect.die("unused"),
115 respondToUserInput: () => Effect.die("unused"),116 respondToUserInput: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1
...@@ -107,6 +107,7 @@ function createProviderServiceHarness(...@@ -107,6 +107,7 @@ function createProviderServiceHarness(
107 const service: ProviderServiceShape = {107 const service: ProviderServiceShape = {
108 startSession: () => unsupported(),108 startSession: () => unsupported(),
109 sendTurn: () => unsupported(),109 sendTurn: () => unsupported(),
110 compactThread: () => unsupported(),
110 interruptTurn: () => unsupported(),111 interruptTurn: () => unsupported(),
111 respondToRequest: () => unsupported(),112 respondToRequest: () => unsupported(),
112 respondToUserInput: () => unsupported(),113 respondToUserInput: () => unsupported(),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+15
...@@ -1275,6 +1275,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1275,6 +1275,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1275 return;1275 return;
1276 }1276 }
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
1278 case "thread.session-set": {1293 case "thread.session-set": {
1279 const turnId = event.payload.session.activeTurnId;1294 const turnId = event.payload.session.activeTurnId;
1280 if (turnId === null || event.payload.session.status !== "running") {1295 if (turnId === null || event.payload.session.status !== "running") {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+24
...@@ -254,6 +254,7 @@ describe("ProviderCommandReactor", () => {...@@ -254,6 +254,7 @@ describe("ProviderCommandReactor", () => {
254 turnId: asTurnId("turn-1"),254 turnId: asTurnId("turn-1"),
255 }),255 }),
256 );256 );
257 const compactThread = vi.fn((_: ThreadId) => Effect.void);
257 const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void);258 const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void);
258 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);259 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
259 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);260 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
...@@ -339,6 +340,7 @@ describe("ProviderCommandReactor", () => {...@@ -339,6 +340,7 @@ describe("ProviderCommandReactor", () => {
339 const service: ProviderServiceShape = {340 const service: ProviderServiceShape = {
340 startSession: startSession as ProviderServiceShape["startSession"],341 startSession: startSession as ProviderServiceShape["startSession"],
341 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],342 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
343 compactThread,
342 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],344 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
343 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],345 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
344 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],346 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
...@@ -533,6 +535,7 @@ describe("ProviderCommandReactor", () => {...@@ -533,6 +535,7 @@ describe("ProviderCommandReactor", () => {
533 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),535 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
534 startSession,536 startSession,
535 sendTurn,537 sendTurn,
538 compactThread,
536 interruptTurn,539 interruptTurn,
537 respondToRequest,540 respondToRequest,
538 respondToUserInput,541 respondToUserInput,
...@@ -632,6 +635,27 @@ describe("ProviderCommandReactor", () => {...@@ -632,6 +635,27 @@ describe("ProviderCommandReactor", () => {
632 }),635 }),
633 );636 );
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 );
635 effectIt.effect("projects starting before a slow provider session finishes", () =>659 effectIt.effect("projects starting before a slow provider session finishes", () =>
636 Effect.gen(function* () {660 Effect.gen(function* () {
637 const releaseStart = yield* Deferred.make<void>();661 const releaseStart = yield* Deferred.make<void>();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+96-21
...@@ -30,7 +30,10 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";...@@ -30,7 +30,10 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3030
31import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";31import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
32import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";32import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
33import { ProviderAdapterRequestError } from "../../provider/Errors.ts";33import {
34 ProviderAdapterRequestError,
35 ProviderAdapterValidationError,
36} from "../../provider/Errors.ts";
34import type { ProviderServiceError } from "../../provider/Errors.ts";37import type { ProviderServiceError } from "../../provider/Errors.ts";
35import { TextGeneration } from "../../textGeneration/TextGeneration.ts";38import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
36import { ProviderService } from "../../provider/Services/ProviderService.ts";39import { ProviderService } from "../../provider/Services/ProviderService.ts";
...@@ -50,6 +53,7 @@ import {...@@ -50,6 +53,7 @@ import {
50import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";53import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
51import { GitWorkflowService } from "../../git/GitWorkflowService.ts";54import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
52const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);55const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
56const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
53const isProviderDriverKind = Schema.is(ProviderDriverKind);57const isProviderDriverKind = Schema.is(ProviderDriverKind);
5458
55type ProviderIntentEvent = Extract<59type ProviderIntentEvent = Extract<
...@@ -72,6 +76,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined...@@ -72,6 +76,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined
72 return normalized && normalized.length > 0 ? normalized : undefined;76 return normalized && normalized.length > 0 ? normalized : undefined;
73}77}
7478
79const isCompactCommandMessage = (message: ThreadTitleMessage): boolean =>
80 message.role === "user" &&
81 (message.attachments?.length ?? 0) === 0 &&
82 message.text.trim().toLowerCase() === "/compact";
75function mapProviderSessionStatusToOrchestrationStatus(83function mapProviderSessionStatusToOrchestrationStatus(
76 status: "connecting" | "ready" | "running" | "error" | "closed",84 status: "connecting" | "ready" | "running" | "error" | "closed",
77): OrchestrationSession["status"] {85): OrchestrationSession["status"] {
...@@ -332,6 +340,7 @@ const make = Effect.gen(function* () {...@@ -332,6 +340,7 @@ const make = Effect.gen(function* () {
332 );340 );
333341
334 const threadModelSelections = new Map<string, ModelSelection>();342 const threadModelSelections = new Map<string, ModelSelection>();
343 const compactingThreadIds = new Set<ThreadId>();
335344
336 const appendProviderFailureActivity = (input: {345 const appendProviderFailureActivity = (input: {
337 readonly threadId: ThreadId;346 readonly threadId: ThreadId;
...@@ -375,11 +384,11 @@ const make = Effect.gen(function* () {...@@ -375,11 +384,11 @@ const make = Effect.gen(function* () {
375384
376 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {385 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {
377 const failReason = cause.reasons.find(Cause.isFailReason);386 const failReason = cause.reasons.find(Cause.isFailReason);
378 const providerError = isProviderAdapterRequestError(failReason?.error)387 if (isProviderAdapterRequestError(failReason?.error)) {
379 ? failReason.error388 return failReason.error.detail;
380 : undefined;389 }
381 if (providerError) {390 if (isProviderAdapterValidationError(failReason?.error)) {
382 return providerError.detail;391 return failReason.error.issue;
383 }392 }
384 return Cause.pretty(cause);393 return Cause.pretty(cause);
385 };394 };
...@@ -1137,7 +1146,6 @@ const make = Effect.gen(function* () {...@@ -1137,7 +1146,6 @@ const make = Effect.gen(function* () {
1137 if (!thread) {1146 if (!thread) {
1138 return;1147 return;
1139 }1148 }
1140
1141 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);1149 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);
1142 if (!message || message.role !== "user") {1150 if (!message || message.role !== "user") {
1143 yield* appendProviderFailureActivity({1151 yield* appendProviderFailureActivity({
...@@ -1147,15 +1155,28 @@ const make = Effect.gen(function* () {...@@ -1147,15 +1155,28 @@ const make = Effect.gen(function* () {
1147 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,1155 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,
1148 turnId: null,1156 turnId: null,
1149 createdAt: event.payload.createdAt,1157 createdAt: event.payload.createdAt,
1158 requestId: event.payload.messageId,
1150 });1159 });
1151 return;1160 return;
1152 }1161 }
1162 const appendTurnStartFailure = (summary: string, detail: string) =>
1163 appendProviderFailureActivity({
1164 threadId: event.payload.threadId,
1165 kind: "provider.turn.start.failed",
1166 summary,
1167 detail,
1168 turnId: null,
1169 createdAt: event.payload.createdAt,
1170 requestId: event.payload.messageId,
1171 });
11531172
1154 yield* ensureThreadWorktree(thread);1173 yield* ensureThreadWorktree(thread);
11551174
1156 const isFirstUserMessageTurn =1175 const isCompactCommand = isCompactCommandMessage(message);
1157 thread.messages.filter((entry) => entry.role === "user").length === 1;1176 const nonCompactUserMessageCount = thread.messages.filter(
1158 if (isFirstUserMessageTurn) {1177 (entry) => entry.role === "user" && !isCompactCommandMessage(entry),
1178 ).length;
1179 if (nonCompactUserMessageCount === 1 && !isCompactCommand) {
1159 const project = yield* resolveProject(thread.projectId);1180 const project = yield* resolveProject(thread.projectId);
1160 const generationCwd =1181 const generationCwd =
1161 resolveThreadWorkspaceCwd({1182 resolveThreadWorkspaceCwd({
...@@ -1194,16 +1215,7 @@ const make = Effect.gen(function* () {...@@ -1194,16 +1215,7 @@ const make = Effect.gen(function* () {
1194 detail,1215 detail,
1195 createdAt: event.payload.createdAt,1216 createdAt: event.payload.createdAt,
1196 }).pipe(1217 }).pipe(
1197 Effect.flatMap(() =>1218 Effect.flatMap(() => appendTurnStartFailure("Provider turn start failed", detail)),
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 ),
1207 Effect.asVoid,1219 Effect.asVoid,
1208 );1220 );
1209 };1221 };
...@@ -1220,6 +1232,69 @@ const make = Effect.gen(function* () {...@@ -1220,6 +1232,69 @@ const make = Effect.gen(function* () {
1220 ),1232 ),
1221 );1233 );
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 }
1223 const sendTurnRequest = yield* buildSendTurnRequestForThread({1298 const sendTurnRequest = yield* buildSendTurnRequestForThread({
1224 threadId: event.payload.threadId,1299 threadId: event.payload.threadId,
1225 messageText: message.text,1300 messageText: message.text,
...@@ -1240,7 +1315,7 @@ const make = Effect.gen(function* () {...@@ -1240,7 +1315,7 @@ const make = Effect.gen(function* () {
12401315
1241 yield* providerService1316 yield* providerService
1242 .sendTurn(sendTurnRequest.value)1317 .sendTurn(sendTurnRequest.value)
1243 .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);1318 .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
1244 });1319 });
12451320
1246 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (1321 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+4-1
...@@ -105,6 +105,7 @@ function createProviderServiceHarness() {...@@ -105,6 +105,7 @@ function createProviderServiceHarness() {
105 const service: ProviderServiceShape = {105 const service: ProviderServiceShape = {
106 startSession: () => unsupported(),106 startSession: () => unsupported(),
107 sendTurn: () => unsupported(),107 sendTurn: () => unsupported(),
108 compactThread: () => unsupported(),
108 interruptTurn: () => unsupported(),109 interruptTurn: () => unsupported(),
109 respondToRequest: () => unsupported(),110 respondToRequest: () => unsupported(),
110 respondToUserInput: () => unsupported(),111 respondToUserInput: () => unsupported(),
...@@ -3221,6 +3222,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3221,6 +3222,8 @@ describe("ProviderRuntimeIngestion", () => {
3221 turnId: asTurnId("turn-1"),3222 turnId: asTurnId("turn-1"),
3222 payload: {3223 payload: {
3223 state: "compacted",3224 state: "compacted",
3225 beforeTokens: 899_000,
3226 afterTokens: 19_000,
3224 detail: { source: "provider" },3227 detail: { source: "provider" },
3225 },3228 },
3226 });3229 });
...@@ -3234,7 +3237,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3234,7 +3237,7 @@ describe("ProviderRuntimeIngestion", () => {
3234 const activity = thread.activities.find(3237 const activity = thread.activities.find(
3235 (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction",3238 (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction",
3236 );3239 );
3237 expect(activity?.summary).toBe("Context compacted");3240 expect(activity?.summary).toBe("Compacted context 899K → 19K tokens");
3238 expect(activity?.tone).toBe("info");3241 expect(activity?.tone).toBe("info");
3239 });3242 });
32403243
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+10-1
...@@ -28,6 +28,7 @@ import * as Layer from "effect/Layer";...@@ -28,6 +28,7 @@ import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";28import * as Option from "effect/Option";
29import * as Stream from "effect/Stream";29import * as Stream from "effect/Stream";
30import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";30import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
31import { formatTokens } from "@t3tools/shared/usageFormat";
3132
32import { ProviderService } from "../../provider/Services/ProviderService.ts";33import { ProviderService } from "../../provider/Services/ProviderService.ts";
33import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";34import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
...@@ -753,15 +754,23 @@ export function runtimeEventToActivities(...@@ -753,15 +754,23 @@ export function runtimeEventToActivities(
753 return [];754 return [];
754 }755 }
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";
756 return [763 return [
757 {764 {
758 id: event.eventId,765 id: event.eventId,
759 createdAt: event.createdAt,766 createdAt: event.createdAt,
760 tone: "info",767 tone: "info",
761 kind: "context-compaction",768 kind: "context-compaction",
762 summary: "Context compacted",769 summary,
763 payload: {770 payload: {
764 state: event.payload.state,771 state: event.payload.state,
772 ...(beforeTokens !== undefined ? { beforeTokens } : {}),
773 ...(afterTokens !== undefined ? { afterTokens } : {}),
765 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),774 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
766 },775 },
767 turnId: toTurnId(event.turnId) ?? null,776 turnId: toTurnId(event.turnId) ?? null,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+7-6
...@@ -729,12 +729,7 @@ describe("ClaudeAdapterLive", () => {...@@ -729,12 +729,7 @@ describe("ClaudeAdapterLive", () => {
729 runtimeMode: "full-access",729 runtimeMode: "full-access",
730 });730 });
731731
732 yield* adapter.sendTurn({732 yield* adapter.compactThread!(session.threadId, modelSelection);
733 threadId: session.threadId,
734 input: "/compact",
735 attachments: [],
736 modelSelection,
737 });
738733
739 const promptText = yield* Effect.promise(() =>734 const promptText = yield* Effect.promise(() =>
740 readFirstPromptText(harness.getLastCreateQueryInput()),735 readFirstPromptText(harness.getLastCreateQueryInput()),
...@@ -2232,6 +2227,12 @@ describe("ClaudeAdapterLive", () => {...@@ -2232,6 +2227,12 @@ describe("ClaudeAdapterLive", () => {
2232 } as unknown as SDKMessage);2227 } as unknown as SDKMessage);
22332228
2234 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));2229 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
2230 const compactionEvent = runtimeEvents.find(
2231 (event) => event.type === "thread.state.changed" && event.payload.state === "compacted",
2232 );
2233 assert.ok(compactionEvent?.type === "thread.state.changed");
2234 assert.equal(compactionEvent.payload.beforeTokens, 200);
2235 assert.equal(compactionEvent.payload.afterTokens, 40);
2235 const finalUsageEvent = runtimeEvents.findLast(2236 const finalUsageEvent = runtimeEvents.findLast(
2236 (event) => event.type === "thread.token-usage.updated",2237 (event) => event.type === "thread.token-usage.updated",
2237 );2238 );
apps/server/src/provider/Layers/ClaudeAdapter.ts+28-13
...@@ -3198,32 +3198,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3198,32 +3198,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3198 },3198 },
3199 });3199 });
3200 return;3200 return;
3201 case "compact_boundary":3201 case "compact_boundary": {
3202 if (context.turnState) {3202 if (context.turnState) {
3203 context.turnState.latestAssistantUsage = undefined;3203 context.turnState.latestAssistantUsage = undefined;
3204 context.turnState.compactedSinceLatestAssistantUsage = true;3204 context.turnState.compactedSinceLatestAssistantUsage = true;
3205 }3205 }
3206 yield* emitThreadTokenUsage(3206 const compactedUsage = compactBoundaryTokenUsageSnapshot(
3207 context,3207 message as unknown as Record<string, unknown>,
3208 compactBoundaryTokenUsageSnapshot(3208 context.lastKnownContextWindow,
3209 message as unknown as Record<string, unknown>,3209 context.lastKnownTotalProcessedTokens,
3210 context.lastKnownContextWindow,
3211 context.lastKnownTotalProcessedTokens,
3212 ),
3213 {
3214 rawMethod: "claude/system/compact_boundary",
3215 rawPayload: message,
3216 },
3217 );3210 );
3211 yield* emitThreadTokenUsage(context, compactedUsage, {
3212 rawMethod: "claude/system/compact_boundary",
3213 rawPayload: message,
3214 });
3218 yield* offerRuntimeEvent({3215 yield* offerRuntimeEvent({
3219 ...base,3216 ...base,
3220 type: "thread.state.changed",3217 type: "thread.state.changed",
3221 payload: {3218 payload: {
3222 state: "compacted",3219 state: "compacted",
3220 ...(compactedUsage?.lastUsedTokens !== undefined
3221 ? { beforeTokens: compactedUsage.lastUsedTokens }
3222 : {}),
3223 ...(compactedUsage?.usedTokens !== undefined
3224 ? { afterTokens: compactedUsage.usedTokens }
3225 : {}),
3223 detail: message,3226 detail: message,
3224 },3227 },
3225 });3228 });
3226 return;3229 return;
3230 }
3227 case "hook_started":3231 case "hook_started":
3228 yield* offerRuntimeEvent({3232 yield* offerRuntimeEvent({
3229 ...base,3233 ...base,
...@@ -3782,7 +3786,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3782,7 +3786,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37823786
3783 // Same reason as the approvals above: a request nobody can answer any more3787 // Same reason as the approvals above: a request nobody can answer any more
3784 // must not stay open, or the thread can never be settled.3788 // must not stay open, or the thread can never be settled.
3785 for (const pending of [...context.pendingUserInputs.values()]) {3789 for (const pending of context.pendingUserInputs.values()) {
3786 yield* pending.cancel;3790 yield* pending.cancel;
3787 }3791 }
37883792
...@@ -4690,6 +4694,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4690,6 +4694,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4690 };4694 };
4691 });4695 });
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
4693 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(4707 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
4694 function* (threadId, _turnId) {4708 function* (threadId, _turnId) {
4695 const context = yield* requireSession(threadId);4709 const context = yield* requireSession(threadId);
...@@ -4804,6 +4818,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4804,6 +4818,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4804 },4818 },
4805 startSession,4819 startSession,
4806 sendTurn,4820 sendTurn,
4821 compactThread,
4807 interruptTurn,4822 interruptTurn,
4808 readThread,4823 readThread,
4809 rollbackThread,4824 rollbackThread,
apps/server/src/provider/Layers/ClaudeProvider.ts+2-7
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
2222
23import {23import {
24 buildServerProvider,24 buildServerProvider,
25 COMPACT_SLASH_COMMAND,
25 DEFAULT_TIMEOUT_MS,26 DEFAULT_TIMEOUT_MS,
26 isCommandMissingCause,27 isCommandMissingCause,
27 parseGenericCliVersion,28 parseGenericCliVersion,
...@@ -503,13 +504,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -503,13 +504,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
503 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))504 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
504 : undefined;505 : undefined;
505 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);506 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
506 const slashCommands = [507 const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
507 {
508 name: "compact",
509 description: "Summarize the conversation and reduce context usage",
510 },
511 ...(capabilities?.slashCommands ?? []),
512 ];
513 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);508 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
514509
515 if (!capabilities) {510 if (!capabilities) {
apps/server/src/provider/Layers/CodexAdapter.test.ts+43
...@@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {...@@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
84 }),84 }),
85 );85 );
8686
87 public readonly compactThread = Effect.void;
88
87 public readonly interruptTurnImpl = vi.fn(89 public readonly interruptTurnImpl = vi.fn(
88 (_turnId?: TurnId): Promise<void> => Promise.resolve(undefined),90 (_turnId?: TurnId): Promise<void> => Promise.resolve(undefined),
89 );91 );
...@@ -336,6 +338,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -336,6 +338,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
336 }),338 }),
337 );339 );
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
339 it.effect("uploads feedback for the active Codex thread", () =>382 it.effect("uploads feedback for the active Codex thread", () =>
340 Effect.gen(function* () {383 Effect.gen(function* () {
341 const adapter = yield* CodexAdapter;384 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+23-1
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type CanonicalItemType,11 type CanonicalItemType,
12 type CanonicalRequestType,12 type CanonicalRequestType,
13 type CodexSettings,13 type CodexSettings,
14 EventId,
14 ProviderDriverKind,15 ProviderDriverKind,
15 type ProviderEvent,16 type ProviderEvent,
16 ProviderInstanceId,17 ProviderInstanceId,
...@@ -1151,7 +1152,18 @@ function mapToRuntimeEvents(...@@ -1151,7 +1152,18 @@ function mapToRuntimeEvents(
1151 ];1152 ];
1152 }1153 }
1153 const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed");1154 const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed");
1154 return completed ? [completed] : [];1155 if (!completed || itemType !== "context_compaction") {
1156 return completed ? [completed] : [];
1157 }
1158 return [
1159 completed,
1160 {
1161 ...runtimeEventBase(event, canonicalThreadId),
1162 eventId: EventId.make(`${event.id}:thread-compacted`),
1163 type: "thread.state.changed",
1164 payload: { state: "compacted" },
1165 },
1166 ];
1155 }1167 }
11561168
1157 if (1169 if (
...@@ -1879,6 +1891,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -1879,6 +1891,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
1879 ),1891 ),
1880 );1892 );
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
1882 const readThread: CodexAdapterShape["readThread"] = (threadId) =>1903 const readThread: CodexAdapterShape["readThread"] = (threadId) =>
1883 requireSession(threadId).pipe(1904 requireSession(threadId).pipe(
1884 Effect.flatMap((session) => session.runtime.readThread),1905 Effect.flatMap((session) => session.runtime.readThread),
...@@ -2014,6 +2035,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2014,6 +2035,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2014 },2035 },
2015 startSession,2036 startSession,
2016 sendTurn,2037 sendTurn,
2038 compactThread,
2017 interruptTurn,2039 interruptTurn,
2018 readThread,2040 readThread,
2019 rollbackThread,2041 rollbackThread,
apps/server/src/provider/Layers/CodexProvider.ts+2
...@@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts...@@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts
30import {30import {
31 AUTH_PROBE_TIMEOUT_MS,31 AUTH_PROBE_TIMEOUT_MS,
32 buildServerProvider,32 buildServerProvider,
33 COMPACT_SLASH_COMMAND,
33 type ServerProviderDraft,34 type ServerProviderDraft,
34} from "../providerSnapshot.ts";35} from "../providerSnapshot.ts";
35import { expandHomePath } from "../../pathExpansion.ts";36import { expandHomePath } from "../../pathExpansion.ts";
...@@ -648,6 +649,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -648,6 +649,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
648 models: snapshot.models,649 models: snapshot.models,
649 skills: snapshot.skills,650 skills: snapshot.skills,
650 slashCommands: [651 slashCommands: [
652 COMPACT_SLASH_COMMAND,
651 {653 {
652 name: "feedback",654 name: "feedback",
653 description: "Send this thread and Codex logs to OpenAI",655 description: "Send this thread and Codex logs to OpenAI",
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5
...@@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape {...@@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape {
196 readonly sendTurn: (196 readonly sendTurn: (
197 input: CodexSessionRuntimeSendTurnInput,197 input: CodexSessionRuntimeSendTurnInput,
198 ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>;198 ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>;
199 readonly compactThread: Effect.Effect<void, CodexSessionRuntimeError>;
199 readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>;200 readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>;
200 readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;201 readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;
201 readonly rollbackThread: (202 readonly rollbackThread: (
...@@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = (...@@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = (
2293 return {2294 return {
2294 start,2295 start,
2295 getSession: Ref.get(sessionRef),2296 getSession: Ref.get(sessionRef),
2297 compactThread: Effect.gen(function* () {
2298 const providerThreadId = yield* readProviderThreadId;
2299 yield* client.request("thread/compact/start", { threadId: providerThreadId });
2300 }),
2296 sendTurn: (input) =>2301 sendTurn: (input) =>
2297 Effect.gen(function* () {2302 Effect.gen(function* () {
2298 const providerThreadId = yield* readProviderThreadId;2303 const providerThreadId = yield* readProviderThreadId;
apps/server/src/provider/Layers/CursorProvider.ts+2
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 buildBooleanOptionDescriptor,34 buildBooleanOptionDescriptor,
35 buildSelectOptionDescriptor,35 buildSelectOptionDescriptor,
36 buildServerProvider,36 buildServerProvider,
37 COMPACT_SLASH_COMMAND,
37 collectStreamAsString,38 collectStreamAsString,
38 isCommandMissingCause,39 isCommandMissingCause,
39 providerModelsFromSettings,40 providerModelsFromSettings,
...@@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: {...@@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: {
639 input.cursorSettings.customModels,640 input.cursorSettings.customModels,
640 EMPTY_CAPABILITIES,641 EMPTY_CAPABILITIES,
641 ),642 ),
643 slashCommands: [COMPACT_SLASH_COMMAND],
642 probe: {644 probe: {
643 installed: true,645 installed: true,
644 version: input.parsed.version,646 version: input.parsed.version,
apps/server/src/provider/Layers/GrokProvider.ts+2
...@@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";...@@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
21import {21import {
22 AUTH_PROBE_TIMEOUT_MS,22 AUTH_PROBE_TIMEOUT_MS,
23 buildServerProvider,23 buildServerProvider,
24 COMPACT_SLASH_COMMAND,
24 isCommandMissingCause,25 isCommandMissingCause,
25 parseGenericCliVersion,26 parseGenericCliVersion,
26 providerModelsFromSettings,27 providerModelsFromSettings,
...@@ -496,6 +497,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -496,6 +497,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
496 checkedAt,497 checkedAt,
497 models,498 models,
498 skills,499 skills,
500 slashCommands: [COMPACT_SLASH_COMMAND],
499 probe: {501 probe: {
500 installed: true,502 installed: true,
501 version,503 version,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+39
...@@ -79,6 +79,7 @@ const runtimeMock = {...@@ -79,6 +79,7 @@ const runtimeMock = {
79 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,79 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
80 messageFailures: 0,80 messageFailures: 0,
81 promptCalls: [] as Array<unknown>,81 promptCalls: [] as Array<unknown>,
82 summarizeCalls: [] as Array<unknown>,
82 promptAsyncError: null as Error | null,83 promptAsyncError: null as Error | null,
83 promptAsyncImplementation: null as (() => Promise<void>) | null,84 promptAsyncImplementation: null as (() => Promise<void>) | null,
84 autoPromptEcho: true,85 autoPromptEcho: true,
...@@ -129,6 +130,7 @@ const runtimeMock = {...@@ -129,6 +130,7 @@ const runtimeMock = {
129 this.state.messageCalls.length = 0;130 this.state.messageCalls.length = 0;
130 this.state.messageFailures = 0;131 this.state.messageFailures = 0;
131 this.state.promptCalls.length = 0;132 this.state.promptCalls.length = 0;
133 this.state.summarizeCalls.length = 0;
132 this.state.promptAsyncError = null;134 this.state.promptAsyncError = null;
133 this.state.promptAsyncImplementation = null;135 this.state.promptAsyncImplementation = null;
134 this.state.autoPromptEcho = true;136 this.state.autoPromptEcho = true;
...@@ -313,6 +315,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -313,6 +315,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
313 });315 });
314 }316 }
315 },317 },
318 summarize: async (input: unknown) => {
319 runtimeMock.state.summarizeCalls.push(input);
320 return { data: true };
321 },
316 messages: async () => ({ data: runtimeMock.state.messages }),322 messages: async () => ({ data: runtimeMock.state.messages }),
317 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {323 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
318 runtimeMock.state.messageCalls.push({ sessionID, messageID });324 runtimeMock.state.messageCalls.push({ sessionID, messageID });
...@@ -949,6 +955,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -949,6 +955,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
949 }),955 }),
950 );956 );
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 );
952 it.effect("falls back to a fresh session when the persisted session is gone", () =>991 it.effect("falls back to a fresh session when the persisted session is gone", () =>
953 Effect.gen(function* () {992 Effect.gen(function* () {
954 const adapter = yield* OpenCodeAdapter;993 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+81
...@@ -2014,6 +2014,21 @@ export function makeOpenCodeAdapter(...@@ -2014,6 +2014,21 @@ export function makeOpenCodeAdapter(
2014 }2014 }
2015 break;2015 break;
2016 }2016 }
2017 case "session.compacted": {
2018 yield* emit({
2019 ...(yield* buildEventBase({
2020 threadId: context.session.threadId,
2021 turnId,
2022 raw: event,
2023 })),
2024 type: "thread.state.changed",
2025 payload: {
2026 state: "compacted",
2027 detail: event,
2028 },
2029 });
2030 break;
2031 }
20172032
2018 case "message.updated": {2033 case "message.updated": {
2019 const promptAdmission = context.promptAdmission;2034 const promptAdmission = context.promptAdmission;
...@@ -2984,6 +2999,71 @@ export function makeOpenCodeAdapter(...@@ -2984,6 +2999,71 @@ export function makeOpenCodeAdapter(
2984 );2999 );
2985 });3000 });
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 });
2987 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(3067 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
2988 function* (threadId, turnId) {3068 function* (threadId, turnId) {
2989 const context = yield* ensureSessionContext(sessions, threadId);3069 const context = yield* ensureSessionContext(sessions, threadId);
...@@ -3268,6 +3348,7 @@ export function makeOpenCodeAdapter(...@@ -3268,6 +3348,7 @@ export function makeOpenCodeAdapter(
3268 },3348 },
3269 startSession,3349 startSession,
3270 sendTurn,3350 sendTurn,
3351 compactThread,
3271 interruptTurn,3352 interruptTurn,
3272 respondToRequest,3353 respondToRequest,
3273 respondToUserInput,3354 respondToUserInput,
apps/server/src/provider/Layers/OpenCodeProvider.ts+2
...@@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";...@@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";
13import { compareSemverVersions } from "@t3tools/shared/semver";13import { compareSemverVersions } from "@t3tools/shared/semver";
14import {14import {
15 buildServerProvider,15 buildServerProvider,
16 COMPACT_SLASH_COMMAND,
16 nonEmptyTrimmed,17 nonEmptyTrimmed,
17 parseGenericCliVersion,18 parseGenericCliVersion,
18 providerModelsFromSettings,19 providerModelsFromSettings,
...@@ -495,6 +496,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu...@@ -495,6 +496,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
495 checkedAt,496 checkedAt,
496 models,497 models,
497 skills,498 skills,
499 slashCommands: [COMPACT_SLASH_COMMAND],
498 probe: {500 probe: {
499 installed: true,501 installed: true,
500 version,502 version,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+4-10
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48import * as ServerConfig from "../../config.ts";48import * as ServerConfig from "../../config.ts";
49import * as ServerSettingsModule from "../../serverSettings.ts";49import * as ServerSettingsModule from "../../serverSettings.ts";
50import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";50import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";
51import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
51import type { ProviderInstance } from "../ProviderDriver.ts";52import type { ProviderInstance } from "../ProviderDriver.ts";
52import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";53import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
53import * as ProviderRegistry from "../Services/ProviderRegistry.ts";54import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
...@@ -384,7 +385,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -384,7 +385,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
384 shortDescription: "Debug failing GitHub Actions checks",385 shortDescription: "Debug failing GitHub Actions checks",
385 },386 },
386 ]);387 ]);
387 assert.deepStrictEqual(status.slashCommands, [388 assert.deepStrictEqual(status.slashCommands.slice(1), [
388 {389 {
389 name: "feedback",390 name: "feedback",
390 description: "Send this thread and Codex logs to OpenAI",391 description: "Send this thread and Codex logs to OpenAI",
...@@ -2439,11 +2440,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2439,11 +2440,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2439 }),2440 }),
2440 );2441 );
24412442
2442 assert.deepStrictEqual(status.slashCommands, [2443 assert.deepStrictEqual(status.slashCommands.slice(1), [
2443 {
2444 name: "compact",
2445 description: "Summarize the conversation and reduce context usage",
2446 },
2447 {2444 {
2448 name: "review",2445 name: "review",
2449 description: "Review a pull request",2446 description: "Review a pull request",
...@@ -2487,10 +2484,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2487,10 +2484,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2487 );2484 );
24882485
2489 assert.deepStrictEqual(status.slashCommands, [2486 assert.deepStrictEqual(status.slashCommands, [
2490 {2487 COMPACT_SLASH_COMMAND,
2491 name: "compact",
2492 description: "Summarize the conversation and reduce context usage",
2493 },
2494 {2488 {
2495 name: "ui",2489 name: "ui",
2496 description: "Explore and refine UI",2490 description: "Explore and refine UI",
apps/server/src/provider/Layers/ProviderService.test.ts+52
...@@ -172,6 +172,18 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {...@@ -172,6 +172,18 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
172 Effect.void,172 Effect.void,
173 );173 );
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 );
175 const respondToRequest = vi.fn(187 const respondToRequest = vi.fn(
176 (188 (
177 _threadId: ThreadId,189 _threadId: ThreadId,
...@@ -250,6 +262,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {...@@ -250,6 +262,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
250 },262 },
251 startSession,263 startSession,
252 sendTurn,264 sendTurn,
265 ...(provider === CODEX_DRIVER ? { compactThread } : {}),
253 interruptTurn,266 interruptTurn,
254 respondToRequest,267 respondToRequest,
255 respondToUserInput,268 respondToUserInput,
...@@ -286,6 +299,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {...@@ -286,6 +299,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
286 updateSession,299 updateSession,
287 startSession,300 startSession,
288 sendTurn,301 sendTurn,
302 compactThread,
289 interruptTurn,303 interruptTurn,
290 respondToRequest,304 respondToRequest,
291 respondToUserInput,305 respondToUserInput,
...@@ -1034,6 +1048,10 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1034,6 +1048,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
1034 });1048 });
1035 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);1049 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
1037 yield* provider.interruptTurn({ threadId: session.threadId });1055 yield* provider.interruptTurn({ threadId: session.threadId });
1038 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]);1056 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]);
10391057
...@@ -1097,6 +1115,40 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1097,6 +1115,40 @@ routing.layer("ProviderServiceLive routing", (it) => {
1097 }),1115 }),
1098 );1116 );
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
1100 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>1152 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>
1101 Effect.gen(function* () {1153 Effect.gen(function* () {
1102 const provider = yield* ProviderService.ProviderService;1154 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+111-8
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 * @module ProviderServiceLive10 * @module ProviderServiceLive
11 */11 */
12import {12import {
13 EventId,
13 ModelSelection,14 ModelSelection,
14 NonNegativeInt,15 NonNegativeInt,
15 ThreadId,16 ThreadId,
...@@ -28,6 +29,7 @@ import {...@@ -28,6 +29,7 @@ import {
28import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";29import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
29import { causeErrorTag } from "@t3tools/shared/observability";30import { causeErrorTag } from "@t3tools/shared/observability";
30import * as DateTime from "effect/DateTime";31import * as DateTime from "effect/DateTime";
32import * as Deferred from "effect/Deferred";
31import * as Effect from "effect/Effect";33import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";34import * as Layer from "effect/Layer";
33import * as Option from "effect/Option";35import * as Option from "effect/Option";
...@@ -49,6 +51,7 @@ import {...@@ -49,6 +51,7 @@ import {
49 providerTurnMetricAttributes,51 providerTurnMetricAttributes,
50 withMetrics,52 withMetrics,
51} from "../../observability/Metrics.ts";53} from "../../observability/Metrics.ts";
54import { ProviderAdapterRequestError } from "../Errors.ts";
52import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";55import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
53import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";56import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
54import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";57import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
...@@ -233,6 +236,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -233,6 +236,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
233 const revokeMcpCredential =236 const revokeMcpCredential =
234 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;237 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
235 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();238 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
239 const pendingCompactions = new Map<
240 ThreadId,
241 { readonly completion: Deferred.Deferred<string>; readonly synthesizeCompactedEvent: boolean }
242 >();
243 const settleCompaction = (threadId: ThreadId, terminal: string) => {
244 const pending = pendingCompactions.get(threadId);
245 pendingCompactions.delete(threadId);
246 return pending
247 ? Deferred.succeed(pending.completion, terminal).pipe(Effect.as(pending))
248 : Effect.succeed(undefined);
249 };
236 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);250 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
237 /**251 /**
238 * Attach the `t3-code` MCP server to the session that is about to start.252 * Attach the `t3-code` MCP server to the session that is about to start.
...@@ -345,14 +359,58 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -345,14 +359,58 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
345 },359 },
346 event: ProviderRuntimeEvent,360 event: ProviderRuntimeEvent,
347 ): Effect.Effect<void> =>361 ): Effect.Effect<void> =>
348 Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe(362 Effect.gen(function* () {
349 Effect.flatMap((canonicalEvent) =>363 const canonicalEvent = yield* Effect.sync(() =>
350 increment(providerRuntimeEventsTotal, {364 correlateRuntimeEventWithInstance(source, event),
351 provider: canonicalEvent.provider,365 );
352 eventType: canonicalEvent.type,366 yield* increment(providerRuntimeEventsTotal, {
353 }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))),367 provider: canonicalEvent.provider,
354 ),368 eventType: canonicalEvent.type,
355 );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
357 // `subscribedAdapters` is our source-of-truth for "which instance adapters415 // `subscribedAdapters` is our source-of-truth for "which instance adapters
358 // are currently wired into the runtime event bus". It both tracks the set416 // are currently wired into the runtime event bus". It both tracks the set
...@@ -854,6 +912,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -854,6 +912,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
854 );912 );
855 });913 });
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
857 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(959 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(
858 function* (rawInput) {960 function* (rawInput) {
859 const input = yield* decodeInputOrValidationError({961 const input = yield* decodeInputOrValidationError({
...@@ -1249,6 +1351,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1249,6 +1351,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1249 return {1351 return {
1250 startSession,1352 startSession,
1251 sendTurn,1353 sendTurn,
1354 compactThread,
1252 interruptTurn,1355 interruptTurn,
1253 respondToRequest,1356 respondToRequest,
1254 respondToUserInput,1357 respondToUserInput,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => {...@@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => {
164 const providerService: ProviderServiceShape = {164 const providerService: ProviderServiceShape = {
165 startSession: () => unsupported(),165 startSession: () => unsupported(),
166 sendTurn: () => unsupported(),166 sendTurn: () => unsupported(),
167 compactThread: () => unsupported(),
167 interruptTurn: () => unsupported(),168 interruptTurn: () => unsupported(),
168 respondToRequest: () => unsupported(),169 respondToRequest: () => unsupported(),
169 respondToUserInput: () => unsupported(),170 respondToUserInput: () => unsupported(),
apps/server/src/provider/Services/ProviderAdapter.ts+5
...@@ -68,6 +68,11 @@ export interface ProviderAdapterShape<TError> {...@@ -68,6 +68,11 @@ export interface ProviderAdapterShape<TError> {
68 input: ProviderSendTurnInput,68 input: ProviderSendTurnInput,
69 ) => Effect.Effect<ProviderTurnStartResult, TError>;69 ) => Effect.Effect<ProviderTurnStartResult, TError>;
7070
71 readonly compactThread?: (
72 threadId: ThreadId,
73 modelSelection?: ProviderSendTurnInput["modelSelection"],
74 ) => Effect.Effect<void, TError>;
75
71 /**76 /**
72 * Interrupt an active turn.77 * Interrupt an active turn.
73 */78 */
apps/server/src/provider/Services/ProviderService.ts+5
...@@ -53,6 +53,11 @@ export interface ProviderServiceShape {...@@ -53,6 +53,11 @@ export interface ProviderServiceShape {
53 input: ProviderSendTurnInput,53 input: ProviderSendTurnInput,
54 ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>;54 ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>;
5555
56 readonly compactThread: (
57 threadId: ThreadId,
58 modelSelection?: ProviderSendTurnInput["modelSelection"],
59 ) => Effect.Effect<void, ProviderServiceError>;
60
56 /**61 /**
57 * Interrupt a running provider turn.62 * Interrupt a running provider turn.
58 */63 */
apps/server/src/provider/providerSnapshot.ts+5
...@@ -22,6 +22,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000;...@@ -22,6 +22,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000;
22// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)22// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
23export const AUTH_PROBE_TIMEOUT_MS = 10_000;23export 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
25export interface CommandResult {30export interface CommandResult {
26 readonly stdout: string;31 readonly stdout: string;
27 readonly stderr: string;32 readonly stderr: string;
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
...@@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>...@@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
51 ({51 ({
52 startSession: () => Effect.die("unused"),52 startSession: () => Effect.die("unused"),
53 sendTurn: () => Effect.die("unused"),53 sendTurn: () => Effect.die("unused"),
54 compactThread: () => Effect.die("unused"),
54 interruptTurn: () => Effect.die("unused"),55 interruptTurn: () => Effect.die("unused"),
55 respondToRequest: () => Effect.die("unused"),56 respondToRequest: () => Effect.die("unused"),
56 respondToUserInput: () => Effect.die("unused"),57 respondToUserInput: () => Effect.die("unused"),
apps/web/src/components/ChatView.tsx+21-19
...@@ -328,7 +328,7 @@ import {...@@ -328,7 +328,7 @@ import {
328import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";328import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
329import { ComposerSurface } from "./chat/ComposerSurface";329import { ComposerSurface } from "./chat/ComposerSurface";
330import {330import {
331 hasAvailableClaudeCompactionProvider,331 hasAvailableCompactionProvider,
332 hasDismissedResumeCompaction,332 hasDismissedResumeCompaction,
333 shouldOfferResumeCompaction,333 shouldOfferResumeCompaction,
334} from "./chat/ContextWindowMeter.logic";334} from "./chat/ContextWindowMeter.logic";
...@@ -2944,13 +2944,23 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2944,13 +2944,23 @@ function ChatViewContent(props: ChatViewProps) {
2944 activeThread?.modelSelection.instanceId ??2944 activeThread?.modelSelection.instanceId ??
2945 activeProject?.defaultModelSelection?.instanceId ??2945 activeProject?.defaultModelSelection?.instanceId ??
2946 null;2946 null;
2947 const compactionProviderAvailable = useMemo(2947 const activeProviderStatus = useMemo(() => {
2948 if (activeProviderInstanceId) {
2949 return (
2950 providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null
2951 );
2952 }
2953 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
2954 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
2955 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
2956 const manualCompactionProviderAvailable = useMemo(
2948 () =>2957 () =>
2949 hasAvailableClaudeCompactionProvider({2958 hasAvailableCompactionProvider({
2950 providers: applyProviderInstanceSettings(2959 providers: applyProviderInstanceSettings(
2951 deriveProviderInstanceEntries(providerStatuses),2960 deriveProviderInstanceEntries(providerStatuses),
2952 settings,2961 settings,
2953 ),2962 ),
2963 driverKind: selectedProvider,
2954 instanceId: activeProviderInstanceId,2964 instanceId: activeProviderInstanceId,
2955 lockedInstanceId: lockedProvider2965 lockedInstanceId: lockedProvider
2956 ? (activeThread?.session?.providerInstanceId ??2966 ? (activeThread?.session?.providerInstanceId ??
...@@ -2964,18 +2974,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2964,18 +2974,10 @@ function ChatViewContent(props: ChatViewProps) {
2964 activeThread?.session?.providerInstanceId,2974 activeThread?.session?.providerInstanceId,
2965 lockedProvider,2975 lockedProvider,
2966 providerStatuses,2976 providerStatuses,
2977 selectedProvider,
2967 settings,2978 settings,
2968 ],2979 ],
2969 );2980 );
2970 const activeProviderStatus = useMemo(() => {
2971 if (activeProviderInstanceId) {
2972 return (
2973 providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null
2974 );
2975 }
2976 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
2977 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
2978 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
2979 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =2981 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
2980 useLocalStorage(2982 useLocalStorage(
2981 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,2983 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
...@@ -5123,12 +5125,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5123,12 +5125,11 @@ function ChatViewContent(props: ChatViewProps) {
5123 activeThread && activeContextWindow5125 activeThread && activeContextWindow
5124 ? `${activeThread.id}:${activeContextWindow.updatedAt}`5126 ? `${activeThread.id}:${activeContextWindow.updatedAt}`
5125 : null;5127 : null;
5126 const compactDisabled =5128 const compactThreadUnavailable =
5127 !activeThread ||5129 !activeThread ||
5128 !activeProject ||5130 !activeProject ||
5129 !isServerThread ||5131 !isServerThread ||
5130 selectedProvider !== "claudeAgent" ||5132 !manualCompactionProviderAvailable ||
5131 !compactionProviderAvailable ||
5132 isWorking ||5133 isWorking ||
5133 threadDetailLoading ||5134 threadDetailLoading ||
5134 isPreparingWorktree ||5135 isPreparingWorktree ||
...@@ -5136,15 +5137,15 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5136,15 +5137,15 @@ function ChatViewContent(props: ChatViewProps) {
5136 feedbackUploading ||5137 feedbackUploading ||
5137 pendingApprovals.length > 0 ||5138 pendingApprovals.length > 0 ||
5138 pendingUserInputs.length > 0 ||5139 pendingUserInputs.length > 0 ||
5139 showPlanFollowUpPrompt ||5140 showPlanFollowUpPrompt;
5140 composerHasUnsentContent;5141 const compactDisabled = compactThreadUnavailable || composerHasUnsentContent;
5141 const compactDisabledReason = compactDisabled5142 const compactDisabledReason = compactDisabled
5142 ? composerHasUnsentContent5143 ? composerHasUnsentContent
5143 ? "Send or clear your draft before compacting"5144 ? "Send or clear your draft before compacting"
5144 : !activeProject5145 : !activeProject
5145 ? "Choose a project before compacting"5146 ? "Choose a project before compacting"
5146 : !compactionProviderAvailable5147 : !manualCompactionProviderAvailable
5147 ? "Enable a Claude provider before compacting"5148 ? "Compaction is unavailable for this provider"
5148 : "Compacting is unavailable right now"5149 : "Compacting is unavailable right now"
5149 : null;5150 : null;
5150 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {5151 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
...@@ -7528,6 +7529,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7528,6 +7529,7 @@ function ChatViewContent(props: ChatViewProps) {
7528 }7529 }
7529 activeThreadModelSelection={activeThread?.modelSelection}7530 activeThreadModelSelection={activeThread?.modelSelection}
7530 activeContextWindow={activeContextWindow}7531 activeContextWindow={activeContextWindow}
7532 compactThreadUnavailable={compactThreadUnavailable}
7531 compactDisabled={compactDisabled}7533 compactDisabled={compactDisabled}
7532 compactDisabledReason={compactDisabledReason}7534 compactDisabledReason={compactDisabledReason}
7533 resolvedTheme={resolvedTheme}7535 resolvedTheme={resolvedTheme}
apps/web/src/components/chat/ChatComposer.tsx+23-8
...@@ -163,7 +163,10 @@ import {...@@ -163,7 +163,10 @@ import {
163 renderProviderTraitsPicker,163 renderProviderTraitsPicker,
164} from "./composerProviderState";164} from "./composerProviderState";
165import { ContextWindowMeter } from "./ContextWindowMeter";165import { ContextWindowMeter } from "./ContextWindowMeter";
166import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";166import {
167 providerSupportsManualCompaction,
168 resolveContextWindowModelDisplayName,
169} from "./ContextWindowMeter.logic";
167import {170import {
168 attachVideoThumbnail,171 attachVideoThumbnail,
169 buildExpandedImagePreview,172 buildExpandedImagePreview,
...@@ -691,6 +694,7 @@ export interface ChatComposerProps {...@@ -691,6 +694,7 @@ export interface ChatComposerProps {
691694
692 // Context window695 // Context window
693 activeContextWindow: ContextWindowSnapshot | null;696 activeContextWindow: ContextWindowSnapshot | null;
697 compactThreadUnavailable: boolean;
694 compactDisabled: boolean;698 compactDisabled: boolean;
695 compactDisabledReason: string | null;699 compactDisabledReason: string | null;
696700
...@@ -787,6 +791,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -787,6 +791,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
787 activeProjectDefaultModelSelection,791 activeProjectDefaultModelSelection,
788 activeThreadModelSelection,792 activeThreadModelSelection,
789 activeContextWindow,793 activeContextWindow,
794 compactThreadUnavailable,
790 compactDisabled,795 compactDisabled,
791 compactDisabledReason,796 compactDisabledReason,
792 resolvedTheme,797 resolvedTheme,
...@@ -1120,6 +1125,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1120,6 +1125,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1120 () => selectedProviderEntry?.snapshot ?? null,1125 () => selectedProviderEntry?.snapshot ?? null,
1121 [selectedProviderEntry],1126 [selectedProviderEntry],
1122 );1127 );
1128 const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry);
1123 const selectedProviderSkills = selectedProviderStatus1129 const selectedProviderSkills = selectedProviderStatus
1124 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)1130 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)
1125 : [];1131 : [];
...@@ -1348,7 +1354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1348,7 +1354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1348 prompt,1354 prompt,
1349 ],1355 ],
1350 );1356 );
1351
1352 // ------------------------------------------------------------------1357 // ------------------------------------------------------------------
1353 // Derived: composer trigger / menu1358 // Derived: composer trigger / menu
1354 // ------------------------------------------------------------------1359 // ------------------------------------------------------------------
...@@ -1360,6 +1365,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1360,6 +1365,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1360 cwd: isPathTrigger ? gitCwd : null,1365 cwd: isPathTrigger ? gitCwd : null,
1361 query: isPathTrigger ? pathTriggerQuery : null,1366 query: isPathTrigger ? pathTriggerQuery : null,
1362 });1367 });
1368 const compactSlashCommandAvailable =
1369 composerTrigger?.kind === "slash-command" &&
1370 !compactThreadUnavailable &&
1371 prompt.slice(composerTrigger.rangeEnd).trim() === "" &&
1372 composerImages.length + composerFiles.length === 0 &&
1373 composerDraft.persistedAttachments.length === 0 &&
1374 composerTerminalContexts.length === 0 &&
1375 composerElementContexts.length === 0 &&
1376 composerPreviewAnnotations.length === 0 &&
1377 composerReviewComments.length === 0;
13631378
1364 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {1379 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
1365 if (!composerTrigger) return [];1380 if (!composerTrigger) return [];
...@@ -1428,8 +1443,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1428,8 +1443,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1428 skill.description ??1443 skill.description ??
1429 (skill.scope ? `${skill.scope} skill` : ""),1444 (skill.scope ? `${skill.scope} skill` : ""),
1430 }));1445 }));
1446 const visibleProviderSlashCommandItems = providerSlashCommandItems.filter(
1447 (item) => item.command.name !== "compact" || compactSlashCommandAvailable,
1448 );
1431 const slashCommandItems = slashCommandItemsForPromptPosition(1449 const slashCommandItems = slashCommandItemsForPromptPosition(
1432 [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems],1450 [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems],
1433 composerTrigger.rangeStart === 0,1451 composerTrigger.rangeStart === 0,
1434 );1452 );
1435 return searchSlashCommandItems(slashCommandItems, query);1453 return searchSlashCommandItems(slashCommandItems, query);
...@@ -1449,6 +1467,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1449,6 +1467,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1449 }1467 }
1450 return [];1468 return [];
1451 }, [1469 }, [
1470 compactSlashCommandAvailable,
1452 composerTrigger,1471 composerTrigger,
1453 planModeUiEnabled,1472 planModeUiEnabled,
1454 selectedProvider,1473 selectedProvider,
...@@ -2298,7 +2317,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2298,7 +2317,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2298 if (2317 if (
2299 compactDisabled ||2318 compactDisabled ||
2300 noProviderAvailable ||2319 noProviderAvailable ||
2301 composerSendState.hasSendableContent ||
2302 activePendingApproval !== null ||2320 activePendingApproval !== null ||
2303 pendingUserInputs.length > 0 ||2321 pendingUserInputs.length > 0 ||
2304 phase === "running" ||2322 phase === "running" ||
...@@ -2336,7 +2354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2336,7 +2354,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2336 activeThreadId,2354 activeThreadId,
2337 compactDisabled,2355 compactDisabled,
2338 composerDraftTarget,2356 composerDraftTarget,
2339 composerSendState.hasSendableContent,
2340 isConnecting,2357 isConnecting,
2341 isSendBusy,2358 isSendBusy,
2342 noProviderAvailable,2359 noProviderAvailable,
...@@ -4402,9 +4419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4402,9 +4419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4402 compactDisabled || noProviderAvailable || isSendBusy || isConnecting4419 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
4403 }4420 }
4404 compactDisabledReason={resolvedCompactDisabledReason}4421 compactDisabledReason={resolvedCompactDisabledReason}
4405 {...(selectedProvider === "claudeAgent"4422 {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})}
4406 ? { onCompactContext: compactThreadContext }
4407 : {})}
4408 />4423 />
4409 </div>4424 </div>
4410 </div>4425 </div>
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+7-5
...@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
3import { deriveProviderInstanceEntries } from "../../providerInstances";3import { deriveProviderInstanceEntries } from "../../providerInstances";
4import {4import {
5 formatContextWindowCompactionMessage,5 formatContextWindowCompactionMessage,
6 hasAvailableClaudeCompactionProvider,6 hasAvailableCompactionProvider,
7 hasDismissedResumeCompaction,7 hasDismissedResumeCompaction,
8 resolveContextWindowModelDisplayName,8 resolveContextWindowModelDisplayName,
9 shouldOfferResumeCompaction,9 shouldOfferResumeCompaction,
...@@ -25,12 +25,12 @@ function claudeProvider(input: {...@@ -25,12 +25,12 @@ function claudeProvider(input: {
25 auth: { status: "authenticated" },25 auth: { status: "authenticated" },
26 checkedAt: "2026-08-24T12:00:00.000Z",26 checkedAt: "2026-08-24T12:00:00.000Z",
27 models: [],27 models: [],
28 slashCommands: [],28 slashCommands: [{ name: "compact", description: "" }],
29 skills: [],29 skills: [],
30 };30 };
31}31}
3232
33describe("hasAvailableClaudeCompactionProvider", () => {33describe("hasAvailableCompactionProvider", () => {
34 const originalInstanceId = ProviderInstanceId.make("claude_original");34 const originalInstanceId = ProviderInstanceId.make("claude_original");
3535
36 it("rejects a fallback in a different locked continuation group", () => {36 it("rejects a fallback in a different locked continuation group", () => {
...@@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {...@@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
47 ]);47 ]);
4848
49 expect(49 expect(
50 hasAvailableClaudeCompactionProvider({50 hasAvailableCompactionProvider({
51 providers,51 providers,
52 driverKind: ProviderDriverKind.make("claudeAgent"),
52 instanceId: originalInstanceId,53 instanceId: originalInstanceId,
53 lockedInstanceId: originalInstanceId,54 lockedInstanceId: originalInstanceId,
54 }),55 }),
...@@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {...@@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
69 ]);70 ]);
7071
71 expect(72 expect(
72 hasAvailableClaudeCompactionProvider({73 hasAvailableCompactionProvider({
73 providers,74 providers,
75 driverKind: ProviderDriverKind.make("claudeAgent"),
74 instanceId: originalInstanceId,76 instanceId: originalInstanceId,
75 lockedInstanceId: originalInstanceId,77 lockedInstanceId: originalInstanceId,
76 }),78 }),
apps/web/src/components/chat/ContextWindowMeter.logic.ts+16-10
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";1import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
2import {2import {
3 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,3 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
4 isClaudeResumeCompactionQuestion,4 isClaudeResumeCompactionQuestion,
...@@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils...@@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils
12export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;12export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
13export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;13export 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: {
16 readonly providers: ReadonlyArray<ProviderInstanceEntry>;22 readonly providers: ReadonlyArray<ProviderInstanceEntry>;
23 readonly driverKind: ProviderDriverKind;
17 readonly instanceId: ProviderInstanceId | null;24 readonly instanceId: ProviderInstanceId | null;
18 readonly lockedInstanceId: ProviderInstanceId | null;25 readonly lockedInstanceId: ProviderInstanceId | null;
19}): boolean {26}): boolean {
20 const claudeProviders = input.providers.filter(27 const driverProviders = input.providers.filter(
21 (provider) => provider.driverKind === "claudeAgent",28 (provider) => provider.driverKind === input.driverKind,
22 );29 );
23 const lockedContinuationGroupKey = input.lockedInstanceId30 const lockedContinuationGroupKey = input.lockedInstanceId
24 ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId)31 ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
25 ?.continuationGroupKey32 ?.continuationGroupKey
26 : undefined;33 : undefined;
27 const compatibleProviders = lockedContinuationGroupKey34 const compatibleProviders = lockedContinuationGroupKey
28 ? claudeProviders.filter(35 ? driverProviders.filter(
29 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,36 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
30 )37 )
31 : claudeProviders;38 : driverProviders;
3239
33 return (40 return providerSupportsManualCompaction(
34 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !==41 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined),
35 undefined
36 );42 );
37}43}
3844
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-2
...@@ -919,7 +919,7 @@ describe("MessagesTimeline", () => {...@@ -919,7 +919,7 @@ describe("MessagesTimeline", () => {
919 entry: {919 entry: {
920 id: "work-1",920 id: "work-1",
921 createdAt: "2026-03-17T19:12:28.000Z",921 createdAt: "2026-03-17T19:12:28.000Z",
922 label: "Context compacted",922 label: "Compacted context 899K → 19K tokens",
923 tone: "info",923 tone: "info",
924 },924 },
925 },925 },
...@@ -927,7 +927,7 @@ describe("MessagesTimeline", () => {...@@ -927,7 +927,7 @@ describe("MessagesTimeline", () => {
927 />,927 />,
928 );928 );
929929
930 expect(markup).toContain("Context compacted");930 expect(markup).toContain("Compacted context 899K → 19K tokens");
931 });931 });
932932
933 it("summarizes changed files in one line", () => {933 it("summarizes changed files in one line", () => {
docs/user/composer.md+2
...@@ -196,6 +196,8 @@ such as System, Personal, Project, or App....@@ -196,6 +196,8 @@ such as System, Personal, Project, or App.
196On mobile, these menus are available on the **New task** screen before you start a thread. They196On mobile, these menus are available on the **New task** screen before you start a thread. They
197use the skills and commands from the selected environment and provider.197use 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
199By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in201By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in
200slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the202slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the
201same `$name` skill token to your message. The original skill name remains searchable. If the provider203same `$name` skill token to your message. The original skill name remains searchable. If the provider
packages/contracts/src/providerRuntime.ts+2
...@@ -298,6 +298,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type;...@@ -298,6 +298,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type;
298298
299const ThreadStateChangedPayload = Schema.Struct({299const ThreadStateChangedPayload = Schema.Struct({
300 state: RuntimeThreadState,300 state: RuntimeThreadState,
301 beforeTokens: Schema.optional(NonNegativeInt),
302 afterTokens: Schema.optional(NonNegativeInt),
301 detail: Schema.optional(Schema.Unknown),303 detail: Schema.optional(Schema.Unknown),
302});304});
303export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type;305export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type;