diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..7515160e648 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -68,6 +68,25 @@ function promptIdFromRequestMeta( return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; } +function promptTextFromRequest(request: AcpSchema.PromptRequest): string { + const blocks = request.prompt; + if (!Array.isArray(blocks)) { + return ""; + } + return blocks + .flatMap((block) => + typeof block === "object" && + block !== null && + "type" in block && + block.type === "text" && + "text" in block && + typeof block.text === "string" + ? [block.text] + : [], + ) + .join(""); +} + function logExit(reason: string): void { if (!exitLogPath) { return; @@ -522,6 +541,21 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + // Mirror real Grok: `/compact` is handled as a prompt and emits auto_compact_completed. + const promptText = promptTextFromRequest(request).trim(); + if (/^\/compact(?:\s|$)/i.test(promptText)) { + writeJsonRpcNotification("_x.ai/session_notification", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "auto_compact_completed", + tokens_before: 12_000, + tokens_after: 4_000, + summary_preview: null, + }, + }); + return { stopReason: "end_turn" }; + } + if (emitXAiPromptCompleteThenHang) { writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..e6166108594 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -188,6 +188,63 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("maps /compact session notifications to thread.state.changed compacted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-compact-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const compacted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "thread.state.changed" && event.payload.state === "compacted" + ? Deferred.succeed(compacted, undefined) + : event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "/compact keep the auth details", + attachments: [], + }); + + yield* Deferred.await(compacted).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + yield* Fiber.interrupt(runtimeEventsFiber); + + const compactEvent = runtimeEvents.find( + (event): event is Extract => + event.type === "thread.state.changed" && event.payload.state === "compacted", + ); + assert.isDefined(compactEvent); + if (compactEvent?.type === "thread.state.changed") { + assert.deepEqual(compactEvent.payload.detail, { + tokensBefore: 12_000, + tokensAfter: 4_000, + }); + assert.equal(compactEvent.raw?.method, "_x.ai/session_notification"); + } + + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + it.effect("closes the ACP child process when a session stops", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-session-close"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caadd..64c54054755 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -61,10 +61,12 @@ import { } from "../acp/GrokAcpSupport.ts"; import { extractXAiAskUserQuestions, + extractXAiAutoCompactCompleted, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiSessionNotification, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -663,6 +665,54 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + // Manual `/compact` and auto-compact complete as x.ai session notifications. + // Map them to thread.state.changed → UI work log "Context compacted". + yield* Effect.forEach( + ["x.ai/session_notification", "_x.ai/session_notification"] as const, + (method) => + acp.handleExtNotification(method, XAiSessionNotification, (notification) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, notification); + const compact = extractXAiAutoCompactCompleted(notification); + if (!compact) { + return; + } + const live = sessions.get(input.threadId); + if (!live || live.stopped) { + return; + } + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + if (turnId !== undefined && live.interruptedTurnIds.has(turnId)) { + return; + } + yield* offerRuntimeEvent({ + type: "thread.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: "compacted", + detail: { + tokensBefore: compact.tokensBefore, + tokensAfter: compact.tokensAfter, + ...(compact.summaryPreview + ? { summaryPreview: compact.summaryPreview } + : {}), + }, + }, + raw: { + source: "acp.grok.extension", + method, + payload: notification, + }, + }); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..7690713aeb6 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -32,6 +32,13 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("Checking Grok"); expect(snapshot.requiresNewThreadForModelChange).toBe(true); + expect(snapshot.slashCommands).toEqual([ + { + name: "compact", + description: "Compress conversation history to reclaim context window", + input: { hint: "optional context about what to preserve" }, + }, + ]); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae..0f53b886f07 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type ServerProvider, type ServerProviderModel, + type ServerProviderSlashCommand, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -44,6 +45,15 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +/** Grok ACP handles `/compact` as prompt text; surface it in the composer slash menu. */ +const GROK_SLASH_COMMANDS: ReadonlyArray = [ + { + name: "compact", + description: "Compress conversation history to reclaim context window", + input: { hint: "optional context about what to preserve" }, + }, +]; + const GROK_BUILT_IN_MODELS: ReadonlyArray = [ { slug: "grok-build", @@ -66,6 +76,7 @@ export function buildInitialGrokProviderSnapshot( enabled: false, checkedAt, models, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: false, version: null, @@ -81,6 +92,7 @@ export function buildInitialGrokProviderSnapshot( enabled: true, checkedAt, models, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: true, version: null, @@ -175,6 +187,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: false, checkedAt, models: fallbackModels, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: false, version: null, @@ -200,6 +213,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: !isCommandMissingCause(error), version: null, @@ -218,6 +232,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: true, version: null, @@ -241,6 +256,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: true, version, @@ -264,6 +280,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: true, version, @@ -282,6 +299,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: true, version, @@ -302,6 +320,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + slashCommands: GROK_SLASH_COMMANDS, probe: { installed: true, version, diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76..7cc778d3f06 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -10,10 +10,12 @@ import { describe, expect } from "vite-plus/test"; import { extractXAiAskUserQuestions, + extractXAiAutoCompactCompleted, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, XAiAskUserQuestionRequest, + XAiSessionNotification, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -102,6 +104,34 @@ describe("XAiAcpExtension", () => { ]); }); + it("extracts auto_compact_completed session notifications", () => { + const notification = Schema.decodeUnknownSync(XAiSessionNotification)({ + sessionId: "session-1", + update: { + sessionUpdate: "auto_compact_completed", + tokens_before: 12_000, + tokens_after: 4_000, + summary_preview: null, + }, + }); + const compact = extractXAiAutoCompactCompleted(notification); + expect(compact).toEqual({ + sessionId: "session-1", + tokensBefore: 12_000, + tokensAfter: 4_000, + summaryPreview: undefined, + raw: notification, + }); + }); + + it("ignores non-compact session notifications", () => { + const notification = Schema.decodeUnknownSync(XAiSessionNotification)({ + sessionId: "session-1", + update: { sessionUpdate: "turn_completed", tokens_before: 1, tokens_after: 1 }, + }); + expect(extractXAiAutoCompactCompleted(notification)).toBeNull(); + }); + it("treats nullable multiSelect from Grok as single-select", () => { const questions = extractXAiAskUserQuestions({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc89..2715610d421 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -196,6 +196,54 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan return { outcome: "cancelled" }; } +/** + * Grok session lifecycle notifications (`_x.ai/session_notification`). + * Manual `/compact` and auto-compact complete as `auto_compact_completed`. + */ +const XAiSessionNotificationUpdate = Schema.Struct({ + sessionUpdate: Schema.String, + tokens_before: Schema.optional(Schema.Number), + tokens_after: Schema.optional(Schema.Number), + summary_preview: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const XAiSessionNotification = Schema.Struct({ + sessionId: Schema.String, + update: XAiSessionNotificationUpdate, + _meta: Schema.optional(Schema.Unknown), +}); + +export type XAiSessionNotification = typeof XAiSessionNotification.Type; + +export interface XAiAutoCompactCompleted { + readonly sessionId: string; + readonly tokensBefore: number | undefined; + readonly tokensAfter: number | undefined; + readonly summaryPreview: string | undefined; + readonly raw: XAiSessionNotification; +} + +function finiteNonNegative(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +/** Returns compact details when the notification is a completed compaction; otherwise null. */ +export function extractXAiAutoCompactCompleted( + notification: XAiSessionNotification, +): XAiAutoCompactCompleted | null { + if (notification.update.sessionUpdate !== "auto_compact_completed") { + return null; + } + const summaryPreview = trimmed(notification.update.summary_preview ?? undefined); + return { + sessionId: notification.sessionId, + tokensBefore: finiteNonNegative(notification.update.tokens_before), + tokensAfter: finiteNonNegative(notification.update.tokens_after), + summaryPreview, + raw: notification, + }; +} + /** * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. * The underlying runtime remains unaware of xAI methods and metadata.