diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index ff2b459558a..27d05648e97 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -23,6 +23,7 @@ import { ThreadCheckpointRevertRequestedPayload as ContractsThreadCheckpointRevertRequestedPayloadSchema, ThreadSessionStopRequestedPayload as ContractsThreadSessionStopRequestedPayloadSchema, ThreadManagerQueueItemsUpsertedPayload as ContractsThreadManagerQueueItemsUpsertedPayloadSchema, + ThreadTrimPointCreatedPayload as ContractsThreadTrimPointCreatedPayloadSchema, } from "@t3tools/contracts"; // Server-internal alias surface, backed by contract schemas as the source of truth. @@ -60,3 +61,5 @@ export const ThreadSessionStopRequestedPayload = ContractsThreadSessionStopReque export const ThreadManagerQueueItemsUpsertedPayload = ContractsThreadManagerQueueItemsUpsertedPayloadSchema; + +export const ThreadTrimPointCreatedPayload = ContractsThreadTrimPointCreatedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.contextTrim.test.ts b/apps/server/src/orchestration/decider.contextTrim.test.ts new file mode 100644 index 00000000000..eef685b6e88 --- /dev/null +++ b/apps/server/src/orchestration/decider.contextTrim.test.ts @@ -0,0 +1,422 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asCommandId = (value: string): CommandId => CommandId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); +const asMessageId = (value: string) => value; + +type PlannedEvent = Omit; + +function makeMessageEvent(input: { + sequence: number; + threadId: ThreadId; + messageId: string; + role: "user" | "assistant"; + text: string; + turnId: TurnId | null; + occurredAt: string; + commandId: CommandId; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: asEventId(`evt-message-${input.messageId}`), + aggregateKind: "thread", + aggregateId: input.threadId, + type: "thread.message-sent", + occurredAt: input.occurredAt, + commandId: input.commandId, + causationEventId: null, + correlationId: input.commandId, + metadata: {}, + payload: { + threadId: input.threadId, + messageId: input.messageId, + role: input.role, + text: input.text, + turnId: input.turnId, + streaming: false, + createdAt: input.occurredAt, + updatedAt: input.occurredAt, + }, + }; +} + +async function seedThreadWithMessages(): Promise { + const now = "2026-01-01T00:00:00.000Z"; + let model = createEmptyReadModel(now); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-trim"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-create"), + causationEventId: null, + correlationId: asCommandId("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-trim"), + title: "Project Trim", + workspaceRoot: "/tmp/project-trim", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 2, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-trim"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create"), + metadata: {}, + payload: { + threadId: asThreadId("thread-trim"), + projectId: asProjectId("project-trim"), + title: "Thread Trim", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const messageEvents: ReadonlyArray = [ + makeMessageEvent({ + sequence: 3, + threadId: asThreadId("thread-trim"), + messageId: "msg-1", + role: "user", + text: "First message", + turnId: asTurnId("turn-1"), + occurredAt: "2026-01-01T00:01:00.000Z", + commandId: asCommandId("cmd-msg-1"), + }), + makeMessageEvent({ + sequence: 4, + threadId: asThreadId("thread-trim"), + messageId: "msg-2", + role: "assistant", + text: "Response 1", + turnId: asTurnId("turn-1"), + occurredAt: "2026-01-01T00:01:01.000Z", + commandId: asCommandId("cmd-msg-2"), + }), + makeMessageEvent({ + sequence: 5, + threadId: asThreadId("thread-trim"), + messageId: "msg-3", + role: "user", + text: "Second message", + turnId: asTurnId("turn-2"), + occurredAt: "2026-01-01T00:02:00.000Z", + commandId: asCommandId("cmd-msg-3"), + }), + makeMessageEvent({ + sequence: 6, + threadId: asThreadId("thread-trim"), + messageId: "msg-4", + role: "assistant", + text: "Response 2", + turnId: asTurnId("turn-2"), + occurredAt: "2026-01-01T00:02:01.000Z", + commandId: asCommandId("cmd-msg-4"), + }), + makeMessageEvent({ + sequence: 7, + threadId: asThreadId("thread-trim"), + messageId: "msg-5", + role: "user", + text: "Third message", + turnId: asTurnId("turn-3"), + occurredAt: "2026-01-01T00:03:00.000Z", + commandId: asCommandId("cmd-msg-5"), + }), + makeMessageEvent({ + sequence: 8, + threadId: asThreadId("thread-trim"), + messageId: "msg-6", + role: "assistant", + text: "Response 3", + turnId: asTurnId("turn-3"), + occurredAt: "2026-01-01T00:03:01.000Z", + commandId: asCommandId("cmd-msg-6"), + }), + makeMessageEvent({ + sequence: 9, + threadId: asThreadId("thread-trim"), + messageId: "msg-unkeyed-1", + role: "user", + text: "Unkeyed message", + turnId: null, + occurredAt: "2026-01-01T00:04:00.000Z", + commandId: asCommandId("cmd-msg-unkeyed"), + }), + ]; + + for (const event of messageEvents) { + model = await Effect.runPromise(projectEvent(model, event)); + } + + return model; +} + +function normalizeTrimEvents(events: PlannedEvent | ReadonlyArray) { + const list = Array.isArray(events) ? events : [events]; + return list.map((event) => { + if (event.type === "thread.trim-point-created") { + return { + type: event.type, + aggregateKind: event.aggregateKind, + aggregateId: event.aggregateId, + commandId: event.commandId, + payload: { + threadId: event.payload.threadId, + trimPoint: { + prunedMessageCount: event.payload.trimPoint.prunedMessageCount, + prunedTurnIds: event.payload.trimPoint.prunedTurnIds, + }, + }, + }; + } + if (event.type === "thread.session-stop-requested") { + return { + type: event.type, + aggregateKind: event.aggregateKind, + aggregateId: event.aggregateId, + commandId: event.commandId, + payload: { + threadId: event.payload.threadId, + }, + }; + } + return event; + }); +} + +describe("thread.context.trim decider", () => { + it("emits trim-point-created and session-stop-requested events for a thread with messages", async () => { + const readModel = await seedThreadWithMessages(); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-all"), + threadId: asThreadId("thread-trim"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel, + }), + ); + + const events = Array.isArray(result) ? result : [result]; + expect(events.map((e) => e.type)).toEqual([ + "thread.trim-point-created", + "thread.session-stop-requested", + ]); + + const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); + expect(trimEvent?.payload.trimPoint.prunedMessageCount).toBeGreaterThan(0); + }); + + it("sets prunedMessageCount to 0 for an empty thread (idempotent)", async () => { + const now = "2026-01-01T00:00:00.000Z"; + let model = createEmptyReadModel(now); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 1, + eventId: asEventId("evt-project-empty"), + aggregateKind: "project", + aggregateId: asProjectId("project-empty"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-empty"), + causationEventId: null, + correlationId: asCommandId("cmd-project-empty"), + metadata: {}, + payload: { + projectId: asProjectId("project-empty"), + title: "Project Empty", + workspaceRoot: "/tmp/project-empty", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + model = await Effect.runPromise( + projectEvent(model, { + sequence: 2, + eventId: asEventId("evt-thread-empty"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-empty"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-empty"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty"), + projectId: asProjectId("project-empty"), + title: "Thread Empty", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-empty"), + threadId: asThreadId("thread-empty"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel: model, + }), + ); + + const events = Array.isArray(result) ? result : [result]; + const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); + expect(trimEvent?.payload.trimPoint.prunedMessageCount).toBe(0); + expect(trimEvent?.payload.trimPoint.prunedTurnIds).toEqual([]); + }); + + it("keeps only the last N turns when keepLastNTurns is specified", async () => { + const readModel = await seedThreadWithMessages(); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-3"), + threadId: asThreadId("thread-trim"), + keepLastNTurns: 2, + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel, + }), + ); + + const events = Array.isArray(result) ? result : [result]; + const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); + const normalized = normalizeTrimEvents(trimEvent!); + expect(normalized[0]?.payload.trimPoint.prunedTurnIds).toEqual([ + asTurnId("turn-1"), + ]); + }); + + it("survives all turns when keepLastNTurns >= total turns", async () => { + const readModel = await seedThreadWithMessages(); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-99"), + threadId: asThreadId("thread-trim"), + keepLastNTurns: 99, + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel, + }), + ); + + const events = Array.isArray(result) ? result : [result]; + const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); + expect(trimEvent?.payload.trimPoint.prunedTurnIds).toEqual([]); + }); + + it("rejects trim on a non-existent thread", async () => { + const readModel = createEmptyReadModel("2026-01-01T00:00:00.000Z"); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-unknown"), + threadId: asThreadId("thread-unknown"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel, + }), + ), + ).rejects.toBeDefined(); + }); + + it("prunes all messages when keepLastNTurns is not specified (/clear without N)", async () => { + const readModel = await seedThreadWithMessages(); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.context.trim", + commandId: asCommandId("cmd-trim-clear-all"), + threadId: asThreadId("thread-trim"), + createdAt: "2026-01-02T00:00:00.000Z", + } as Extract, + readModel, + }), + ); + + const events = Array.isArray(result) ? result : [result]; + const trimEvent = events.find((e) => e.type === "thread.trim-point-created"); + expect(trimEvent?.payload.trimPoint.prunedMessageCount).toBe(7); + expect(trimEvent?.payload.trimPoint.prunedTurnIds).toEqual([ + asTurnId("turn-1"), + asTurnId("turn-2"), + asTurnId("turn-3"), + ]); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 01edab688d7..5c087d37c3c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,8 +1,10 @@ import { + ContextTrimPoint, EventId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + TurnId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -1031,6 +1033,108 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.context.trim": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + + const messagesByTurn = new Map< + string, + { turnId: string; messages: typeof thread.messages } + >(); + for (const msg of thread.messages) { + if (msg.turnId === null) continue; + const existing = messagesByTurn.get(msg.turnId); + if (existing) { + existing.messages.push(msg); + } else { + messagesByTurn.set(msg.turnId, { turnId: msg.turnId, messages: [msg] }); + } + } + + const orderedTurns = [...messagesByTurn.values()].toSorted((a, b) => { + const aFirst = a.messages.reduce( + (earliest, m) => (m.createdAt < earliest ? m.createdAt : earliest), + a.messages[0]!.createdAt, + ); + const bFirst = b.messages.reduce( + (earliest, m) => (m.createdAt < earliest ? m.createdAt : earliest), + b.messages[0]!.createdAt, + ); + return aFirst.localeCompare(bFirst); + }); + + const totalTurns = orderedTurns.length; + const keepN = command.keepLastNTurns ?? 0; + + const survivingTurnIds = new Set(); + if (keepN > 0 && totalTurns > 0) { + const keepCount = Math.min(keepN, totalTurns); + for (let i = totalTurns - keepCount; i < totalTurns; i++) { + survivingTurnIds.add(orderedTurns[i]!.turnId); + } + } + + let beforeEntryId = ""; + let prunedMessageCount = 0; + const prunedTurnIds: TurnId[] = []; + + for (const turn of orderedTurns) { + if (survivingTurnIds.has(turn.turnId)) { + if (beforeEntryId === "") { + beforeEntryId = turn.messages[0]?.id ?? ""; + } + } else { + prunedTurnIds.push(TurnId.make(turn.turnId)); + prunedMessageCount += turn.messages.length; + } + } + + const unkeyedMessages = thread.messages.filter((m) => m.turnId === null); + prunedMessageCount += unkeyedMessages.length; + + const occurredAt = yield* nowIso; + const trimPointId = EventId.make(crypto.randomUUID()); + const trimPoint: ContextTrimPoint = { + id: trimPointId, + createdAt: occurredAt, + beforeEntryId, + prunedMessageCount, + prunedTurnIds, + }; + + return [ + { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.trim-point-created", + payload: { + threadId: command.threadId, + trimPoint, + }, + }, + { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.session-stop-requested", + payload: { + threadId: command.threadId, + createdAt: occurredAt, + }, + }, + ]; + } + case "thread.session.set": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9d3568a9db5..7814704cf9e 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -915,4 +915,189 @@ describe("orchestration projector", () => { expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); }); + + it("appends trim point to contextTrimPoints for existing thread", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-01T00:10:00.000Z"; + const model = createEmptyReadModel(now); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-trim-test", + occurredAt: now, + commandId: "cmd-create", + payload: { + threadId: "thread-trim-test", + projectId: "project-1", + title: "trim test", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + const afterTrim = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.trim-point-created", + aggregateKind: "thread", + aggregateId: "thread-trim-test", + occurredAt: later, + commandId: "cmd-trim", + payload: { + threadId: "thread-trim-test", + trimPoint: { + id: EventId.make("trim-1"), + createdAt: later, + beforeEntryId: "", + prunedMessageCount: 5, + prunedTurnIds: [], + }, + }, + }), + ), + ); + + expect(afterTrim.threads[0]?.contextTrimPoints).toEqual([ + { + id: "trim-1", + createdAt: later, + beforeEntryId: "", + prunedMessageCount: 5, + prunedTurnIds: [], + }, + ]); + }); + + it("leaves read model unchanged when trim point targets unknown thread", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const model = createEmptyReadModel(now); + + const next = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.trim-point-created", + aggregateKind: "thread", + aggregateId: "thread-unknown", + occurredAt: "2026-01-01T00:10:00.000Z", + commandId: "cmd-trim", + payload: { + threadId: "thread-unknown", + trimPoint: { + id: EventId.make("trim-1"), + createdAt: "2026-01-01T00:10:00.000Z", + beforeEntryId: "", + prunedMessageCount: 0, + prunedTurnIds: [], + }, + }, + }), + ), + ); + + expect(next.threads).toEqual([]); + expect(next.snapshotSequence).toBe(1); + }); + + it("sorts multiple trim points by createdAt then id", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const model = createEmptyReadModel(now); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-sort-test", + occurredAt: now, + commandId: "cmd-create", + payload: { + threadId: "thread-sort-test", + projectId: "project-1", + title: "sort test", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + let state = afterCreate; + state = await Effect.runPromise( + projectEvent( + state, + makeEvent({ + sequence: 2, + type: "thread.trim-point-created", + aggregateKind: "thread", + aggregateId: "thread-sort-test", + occurredAt: "2026-01-01T00:20:00.000Z", + commandId: "cmd-trim-2", + payload: { + threadId: "thread-sort-test", + trimPoint: { + id: EventId.make("trim-2"), + createdAt: "2026-01-01T00:20:00.000Z", + beforeEntryId: "", + prunedMessageCount: 3, + prunedTurnIds: [], + }, + }, + }), + ), + ); + + state = await Effect.runPromise( + projectEvent( + state, + makeEvent({ + sequence: 3, + type: "thread.trim-point-created", + aggregateKind: "thread", + aggregateId: "thread-sort-test", + occurredAt: "2026-01-01T00:10:00.000Z", + commandId: "cmd-trim-1", + payload: { + threadId: "thread-sort-test", + trimPoint: { + id: EventId.make("trim-1"), + createdAt: "2026-01-01T00:10:00.000Z", + beforeEntryId: "", + prunedMessageCount: 2, + prunedTurnIds: [], + }, + }, + }), + ), + ); + + const trimPoints = state.threads[0]?.contextTrimPoints ?? []; + expect(trimPoints.map((tp) => tp.id)).toEqual(["trim-1", "trim-2"]); + }); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index e3a429a2b18..bb5184072c2 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -28,6 +28,7 @@ import { ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, ThreadManagerQueueItemsUpsertedPayload, + ThreadTrimPointCreatedPayload, } from "./Schemas.ts"; type ThreadPatch = Partial>; @@ -691,6 +692,31 @@ export function projectEvent( })), ); + case "thread.trim-point-created": + return decodeForEvent( + ThreadTrimPointCreatedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const contextTrimPoints = [...thread.contextTrimPoints, payload.trimPoint].toSorted( + (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), + ); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + contextTrimPoints, + updatedAt: event.occurredAt, + }), + }; + }), + ); + default: return Effect.succeed(nextBase); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cc66f467dc7..2a6d3313f82 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1774,11 +1774,13 @@ export default function ChatView(props: ChatViewProps) { workLogEntries, activeThread?.managerMetadata, activeThread?.createdAt, + activeThread?.contextTrimPoints, ), [ activeThread?.managerMetadata, activeThread?.createdAt, activeThread?.proposedPlans, + activeThread?.contextTrimPoints, timelineMessages, workLogEntries, ], @@ -2928,7 +2930,19 @@ export default function ChatView(props: ChatViewProps) { ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { - handleInteractionModeChange(standaloneSlashCommand); + if (typeof standaloneSlashCommand === "object" && standaloneSlashCommand.command === "clear") { + void api.orchestration.dispatchCommand({ + type: "thread.context.trim", + commandId: newCommandId(), + threadId: activeThread.id, + ...(standaloneSlashCommand.keepLastNTurns !== undefined + ? { keepLastNTurns: standaloneSlashCommand.keepLastNTurns } + : {}), + createdAt: new Date().toISOString(), + }); + } else { + handleInteractionModeChange(standaloneSlashCommand); + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 78d93187974..9260b8e4681 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -883,6 +883,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/default", description: "Switch this thread back to normal build mode", }, + { + id: "slash:clear", + type: "slash-command", + command: "clear", + label: "/clear", + description: "Clear conversation context and restart session", + }, ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( (command) => ({ @@ -1507,6 +1514,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "clear") { + const replacement = "/clear "; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), diff --git a/apps/web/src/components/chat/ContextTrimPointDivider.tsx b/apps/web/src/components/chat/ContextTrimPointDivider.tsx new file mode 100644 index 00000000000..5449869682b --- /dev/null +++ b/apps/web/src/components/chat/ContextTrimPointDivider.tsx @@ -0,0 +1,41 @@ +import { type ContextTrimPoint } from "@t3tools/contracts"; +import { ChevronDownIcon } from "lucide-react"; +import { memo, useState } from "react"; +import { Button } from "../ui/button"; +import { cn } from "~/lib/utils"; + +interface ContextTrimPointDividerProps { + trimPoint: ContextTrimPoint; +} + +export const ContextTrimPointDivider = memo(function ContextTrimPointDivider({ + trimPoint, +}: ContextTrimPointDividerProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + + +
+ ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 02235d0bb0b..fed4903097c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -650,3 +650,85 @@ describe("computeStableMessagesTimelineRows", () => { expect(updated).not.toBe(initial); }); }); + +describe("deriveMessagesTimelineRows context-trim", () => { + it("produces context-trim rows from timeline entries", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "entry-trim-1", + kind: "context-trim", + createdAt: "2026-01-01T00:00:05Z", + trimPoint: { + id: "trim-1" as never, + createdAt: "2026-01-01T00:00:05Z", + beforeEntryId: "msg-1", + prunedMessageCount: 10, + prunedTurnIds: ["turn-1" as never, "turn-2" as never], + }, + }, + { + id: "entry-user-1", + kind: "message", + createdAt: "2026-01-01T00:00:10Z", + message: { + id: "user-1" as never, + role: "user", + text: "Hello", + turnId: null, + createdAt: "2026-01-01T00:00:10Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows).toHaveLength(2); + expect(rows[0]!.kind).toBe("context-trim"); + if (rows[0]!.kind === "context-trim") { + expect(rows[0]!.id).toBe("entry-trim-1"); + expect(rows[0]!.trimPoint.prunedMessageCount).toBe(10); + expect(rows[0]!.trimPoint.beforeEntryId).toBe("msg-1"); + } + expect(rows[1]!.kind).toBe("message"); + }); + + it("context-trim rows are stable across identical invocations", () => { + const createRows = () => + deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "entry-trim-1", + kind: "context-trim", + createdAt: "2026-01-01T00:00:05Z", + trimPoint: { + id: "trim-1" as never, + createdAt: "2026-01-01T00:00:05Z", + beforeEntryId: "msg-1", + prunedMessageCount: 10, + prunedTurnIds: ["turn-1" as never], + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const initial = computeStableMessagesTimelineRows(createRows(), { + byId: new Map(), + result: [], + }); + + const repeated = computeStableMessagesTimelineRows(createRows(), initial); + + expect(repeated).toBe(initial); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 06d7a3c164b..ea5287df943 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,7 @@ import * as Equal from "effect/Equal"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { type ContextTrimPoint, type MessageId, type TurnId } from "@t3tools/contracts"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; @@ -46,6 +46,12 @@ export type MessagesTimelineRow = refinedBrief: string; acceptanceCriteria: readonly string[]; sourceBody: string; + } + | { + kind: "context-trim"; + id: string; + createdAt: string; + trimPoint: ContextTrimPoint; }; export interface StableMessagesTimelineRowsState { @@ -182,6 +188,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.kind === "context-trim") { + nextRows.push({ + kind: "context-trim", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + trimPoint: timelineEntry.trimPoint, + }); + continue; + } + const assistantTurnStillInProgress = timelineEntry.message.role === "assistant" && input.activeTurnInProgress === true && @@ -284,5 +300,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.sourceBody === bm.sourceBody ); } + + case "context-trim": { + return Equal.equals(a.trimPoint, (b as typeof a).trimPoint); + } } } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d60872d75e8..8cf5787a8f2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -46,6 +46,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; +import { ContextTrimPointDivider } from "./ContextTrimPointDivider"; import { computeStableMessagesTimelineRows, MAX_VISIBLE_WORK_LOG_ENTRIES, @@ -330,6 +331,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} {row.kind === "manager-instruction" ? : null} + {row.kind === "context-trim" ? ( + + ) : null} ); }); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts index bf7ab3e1d9a..2a0548ddf52 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -42,6 +42,63 @@ describe("searchSlashCommandItems", () => { ]); }); + it("finds the /clear built-in slash command", () => { + const items = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + { + id: "slash:clear", + type: "slash-command", + command: "clear", + label: "/clear", + description: "Clear conversation context and restart session", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "clear").map((item) => item.id)).toEqual([ + "slash:clear", + ]); + }); + + it("finds /clear with number suffix as prefix match", () => { + const items = [ + { + id: "slash:clear", + type: "slash-command", + command: "clear", + label: "/clear", + description: "Clear conversation context and restart session", + }, + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + ] satisfies Array< + Extract + >; + + expect(searchSlashCommandItems(items, "clear 3").map((item) => item.id)).toEqual([ + "slash:clear", + ]); + }); + it("supports fuzzy provider command matches", () => { const items = [ { diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 91c323638d7..928e0657949 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -324,6 +324,32 @@ describe("parseStandaloneComposerSlashCommand", () => { expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); }); + it("parses standalone /clear command", () => { + expect(parseStandaloneComposerSlashCommand("/clear")).toEqual({ command: "clear" }); + }); + + it("parses standalone /clear N command", () => { + expect(parseStandaloneComposerSlashCommand("/clear 3")).toEqual({ + command: "clear", + keepLastNTurns: 3, + }); + }); + + it("parses /clear with surrounding whitespace", () => { + expect(parseStandaloneComposerSlashCommand(" /clear 5 ")).toEqual({ + command: "clear", + keepLastNTurns: 5, + }); + }); + + it("ignores /clear with extra message text", () => { + expect(parseStandaloneComposerSlashCommand("/clear explain this")).toBeNull(); + }); + + it("returns null for /clear with non-numeric argument", () => { + expect(parseStandaloneComposerSlashCommand("/clear abc")).toBeNull(); + }); + it("ignores slash commands with extra message text", () => { expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index ef3243f4aa7..d45618a0329 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -3,7 +3,12 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; export type ComposerTriggerKind = "path" | "slash-command" | "skill"; -export type ComposerSlashCommand = "model" | "plan" | "default"; +export type ComposerSlashCommand = "model" | "plan" | "default" | "clear"; + +export interface ComposerClearSlashCommand { + command: "clear"; + keepLastNTurns?: number; +} export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -258,8 +263,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, -): Exclude | null { - const match = /^\/(plan|default)\s*$/i.exec(text.trim()); +): "plan" | "default" | ComposerClearSlashCommand | null { + const trimmed = text.trim(); + const clearMatch = /^\/clear(?:\s+(\d+))?\s*$/i.exec(trimmed); + if (clearMatch) { + const n = clearMatch[1]; + return { + command: "clear", + ...(n !== undefined ? { keepLastNTurns: parseInt(n, 10) } : {}), + }; + } + const match = /^\/(plan|default)\s*$/i.exec(trimmed); if (!match) { return null; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 6f5db658277..166b5531fe2 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -3,6 +3,7 @@ import { MessageId, ThreadId, TurnId, + type ContextTrimPoint, type OrchestrationThreadActivity, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -1621,3 +1622,102 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:11:00.000Z"); }); }); + +describe("deriveTimelineEntries with context trim points", () => { + function makeTrimPoint(overrides: { + id?: string; + createdAt?: string; + beforeEntryId?: string; + prunedMessageCount?: number; + prunedTurnIds?: string[]; + }): ContextTrimPoint { + return { + id: EventId.make(overrides.id ?? "trim-1"), + createdAt: overrides.createdAt ?? "2026-02-23T00:00:05.000Z", + beforeEntryId: overrides.beforeEntryId ?? "msg-surviving", + prunedMessageCount: overrides.prunedMessageCount ?? 5, + prunedTurnIds: (overrides.prunedTurnIds ?? ["turn-1", "turn-2"]).map((id) => + TurnId.make(id), + ), + }; + } + + it("produces context-trim entries sorted inline with messages", () => { + const entries = deriveTimelineEntries( + [ + { + id: MessageId.make("msg-1"), + role: "user", + text: "hello", + createdAt: "2026-02-23T00:00:01.000Z", + streaming: false, + }, + { + id: MessageId.make("msg-2"), + role: "assistant", + text: "hi", + createdAt: "2026-02-23T00:00:10.000Z", + streaming: false, + }, + ], + [], + [], + undefined, + undefined, + [ + makeTrimPoint({ + id: "trim-1", + createdAt: "2026-02-23T00:00:05.000Z", + }), + ], + ); + + expect(entries.map((e) => e.kind)).toEqual(["message", "context-trim", "message"]); + expect(entries[1]).toMatchObject({ + kind: "context-trim", + id: "trim-1", + }); + }); + + it("sorts multiple trim points chronologically", () => { + const entries = deriveTimelineEntries( + [], + [], + [], + undefined, + undefined, + [ + makeTrimPoint({ + id: "trim-2", + createdAt: "2026-02-23T00:00:10.000Z", + }), + makeTrimPoint({ + id: "trim-1", + createdAt: "2026-02-23T00:00:05.000Z", + }), + ], + ); + + expect(entries.map((e) => e.id)).toEqual(["trim-1", "trim-2"]); + }); + + it("returns empty array when contextTrimPoints is undefined", () => { + const entries = deriveTimelineEntries([], [], []); + expect(entries).toEqual([]); + }); + + it("embeds trim point detail in the timeline entry", () => { + const trimPoint = makeTrimPoint({ + prunedMessageCount: 47, + prunedTurnIds: ["turn-a", "turn-b"], + beforeEntryId: "msg-abc", + }); + const entries = deriveTimelineEntries([], [], [], undefined, undefined, [trimPoint]); + + expect(entries).toHaveLength(1); + if (entries[0]?.kind === "context-trim") { + expect(entries[0].trimPoint.prunedMessageCount).toBe(47); + expect(entries[0].trimPoint.beforeEntryId).toBe("msg-abc"); + } + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a6db7bc10e8..3f87137d6d7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -3,6 +3,7 @@ import * as Arr from "effect/Array"; import { ApprovalRequestId, isToolLifecycleItemType, + type ContextTrimPoint, type ManagerThreadMetadata, type OrchestrationLatestTurn, type OrchestrationThreadActivity, @@ -127,6 +128,12 @@ export type TimelineEntry = refinedBrief: string; acceptanceCriteria: readonly string[]; sourceBody: string; + } + | { + id: string; + kind: "context-trim"; + createdAt: string; + trimPoint: ContextTrimPoint; }; export function formatDuration(durationMs: number): string { @@ -1177,6 +1184,7 @@ export function deriveTimelineEntries( workEntries: WorkLogEntry[], managerMetadata?: ManagerThreadMetadata, threadCreatedAt?: string, + contextTrimPoints?: ReadonlyArray, ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1196,8 +1204,15 @@ export function deriveTimelineEntries( createdAt: entry.createdAt, entry, })); - const sorted = [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => - a.createdAt.localeCompare(b.createdAt), + const trimPointRows: TimelineEntry[] = + contextTrimPoints?.map((trimPoint) => ({ + id: trimPoint.id, + kind: "context-trim", + createdAt: trimPoint.createdAt, + trimPoint, + })) ?? []; + const sorted = [...messageRows, ...proposedPlanRows, ...workRows, ...trimPointRows].toSorted( + (a, b) => a.createdAt.localeCompare(b.createdAt), ); if (managerMetadata?.role === "worker") { diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index a6301757ca0..92373b1951b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -25,6 +25,10 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, + ThreadContextTrimCommand, + ContextTrimPoint, + ThreadTrimPointCreatedPayload, + OrchestrationThread, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -936,3 +940,176 @@ it.effect("changeRequest.getPrDiff result decodes diff string", () => assert.strictEqual(parsed.diff.startsWith("---"), true); }), ); + +// ── Context trim command & event ───────────────────────────────────── + +const decodeThreadContextTrimCommand = Schema.decodeUnknownEffect(ThreadContextTrimCommand); +const decodeContextTrimPoint = Schema.decodeUnknownEffect(ContextTrimPoint); +const decodeThreadTrimPointCreatedPayload = Schema.decodeUnknownEffect(ThreadTrimPointCreatedPayload); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); + +it.effect("decodes thread.context.trim command", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadContextTrimCommand({ + type: "thread.context.trim", + commandId: "cmd-trim-1", + threadId: "thread-1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.type, "thread.context.trim"); + assert.strictEqual(parsed.keepLastNTurns, undefined); + }), +); + +it.effect("decodes thread.context.trim command with keepLastNTurns", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadContextTrimCommand({ + type: "thread.context.trim", + commandId: "cmd-trim-3", + threadId: "thread-1", + keepLastNTurns: 3, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.keepLastNTurns, 3); + }), +); + +it.effect("decodes ContextTrimPoint", () => + Effect.gen(function* () { + const parsed = yield* decodeContextTrimPoint({ + id: "trim-1", + createdAt: "2026-01-01T00:00:00.000Z", + beforeEntryId: "msg-5", + prunedMessageCount: 10, + prunedTurnIds: ["turn-1", "turn-2"], + }); + assert.strictEqual(parsed.id, "trim-1"); + assert.strictEqual(parsed.beforeEntryId, "msg-5"); + assert.strictEqual(parsed.prunedMessageCount, 10); + assert.deepStrictEqual(parsed.prunedTurnIds, ["turn-1", "turn-2"]); + }), +); + +it.effect("decodes thread.trim-point-created payload", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTrimPointCreatedPayload({ + threadId: "thread-1", + trimPoint: { + id: "trim-1", + createdAt: "2026-01-01T00:00:00.000Z", + beforeEntryId: "msg-5", + prunedMessageCount: 10, + prunedTurnIds: ["turn-1", "turn-2"], + }, + }); + assert.strictEqual(parsed.threadId, "thread-1"); + assert.strictEqual(parsed.trimPoint.prunedMessageCount, 10); + }), +); + +it.effect("decodes thread.trim-point-created event", () => + Effect.gen(function* () { + const event = yield* decodeOrchestrationEvent({ + sequence: 100, + eventId: "event-trim-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.trim-point-created", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-trim-1", + causationEventId: null, + correlationId: "cmd-trim-1", + metadata: {}, + payload: { + threadId: "thread-1", + trimPoint: { + id: "trim-1", + createdAt: "2026-01-01T00:00:00.000Z", + beforeEntryId: "msg-5", + prunedMessageCount: 10, + prunedTurnIds: ["turn-1", "turn-2"], + }, + }, + }); + assert.strictEqual(event.type, "thread.trim-point-created"); + if (event.type === "thread.trim-point-created") { + assert.strictEqual(event.payload.trimPoint.beforeEntryId, "msg-5"); + assert.strictEqual(event.payload.trimPoint.prunedMessageCount, 10); + } + }), +); + +it.effect("OrchestrationThread defaults contextTrimPoints to empty array", () => + Effect.gen(function* () { + const thread = yield* decodeOrchestrationThread({ + id: "thread-1", + projectId: "project-1", + title: "Test Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + messages: [], + activities: [], + checkpoints: [], + session: null, + }); + assert.deepStrictEqual(thread.contextTrimPoints, []); + }), +); + +it.effect("OrchestrationThread decodes explicit contextTrimPoints", () => + Effect.gen(function* () { + const thread = yield* decodeOrchestrationThread({ + id: "thread-1", + projectId: "project-1", + title: "Test Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + messages: [], + activities: [], + checkpoints: [], + contextTrimPoints: [ + { + id: "trim-1", + createdAt: "2026-01-01T00:00:00.000Z", + beforeEntryId: "msg-5", + prunedMessageCount: 10, + prunedTurnIds: ["turn-1", "turn-2"], + }, + ], + session: null, + }); + assert.strictEqual(thread.contextTrimPoints.length, 1); + assert.strictEqual(thread.contextTrimPoints[0]?.prunedMessageCount, 10); + }), +); + +it.effect("decodes thread.context.trim as part of OrchestrationCommand union", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.context.trim", + commandId: "cmd-trim-1", + threadId: "thread-1", + keepLastNTurns: 5, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.type, "thread.context.trim"); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3a3c07146e7..8896baea966 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -463,6 +463,15 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ContextTrimPoint = Schema.Struct({ + id: EventId, + createdAt: IsoDateTime, + beforeEntryId: Schema.String, + prunedMessageCount: NonNegativeInt, + prunedTurnIds: Schema.Array(TurnId), +}); +export type ContextTrimPoint = typeof ContextTrimPoint.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -492,6 +501,9 @@ export const OrchestrationThread = Schema.Struct({ ), activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), + contextTrimPoints: Schema.Array(ContextTrimPoint).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), session: Schema.NullOr(OrchestrationSession), }); export type OrchestrationThread = typeof OrchestrationThread.Type; @@ -904,6 +916,15 @@ const ThreadSessionStopCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadContextTrimCommand = Schema.Struct({ + type: Schema.Literal("thread.context.trim"), + commandId: CommandId, + threadId: ThreadId, + keepLastNTurns: Schema.optional(NonNegativeInt), + createdAt: IsoDateTime, +}); +export type ThreadContextTrimCommand = typeof ThreadContextTrimCommand.Type; + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ManagerBootstrapCommand, @@ -930,6 +951,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadContextTrimCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -960,6 +982,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadContextTrimCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -1071,6 +1094,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.turn-diff-completed", "thread.activity-appended", "thread.manager-queue-items-upserted", + "thread.trim-point-created", ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; @@ -1270,6 +1294,11 @@ export const ThreadManagerQueueItemsUpsertedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadTrimPointCreatedPayload = Schema.Struct({ + threadId: ThreadId, + trimPoint: ContextTrimPoint, +}); + export const OrchestrationEventMetadata = Schema.Struct({ providerTurnId: Schema.optional(TrimmedNonEmptyString), providerItemId: Schema.optional(ProviderItemId), @@ -1417,6 +1446,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.manager-queue-items-upserted"), payload: ThreadManagerQueueItemsUpsertedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.trim-point-created"), + payload: ThreadTrimPointCreatedPayload, + }), ]); export type OrchestrationEvent = typeof OrchestrationEvent.Type;