Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/provider/Layers/GrokAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const turnCompleted = yield* Deferred.make<void>();
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<ProviderRuntimeEvent, { type: "thread.state.changed" }> =>
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");
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/provider/Layers/GrokAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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* () {
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/Layers/GrokProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
]);
}),
);
});
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/provider/Layers/GrokProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ServerProviderSlashCommand> = [
{
name: "compact",
description: "Compress conversation history to reclaim context window",
input: { hint: "optional context about what to preserve" },
},
];

const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "grok-build",
Expand All @@ -66,6 +76,7 @@ export function buildInitialGrokProviderSnapshot(
enabled: false,
checkedAt,
models,
slashCommands: GROK_SLASH_COMMANDS,
probe: {
installed: false,
version: null,
Expand All @@ -81,6 +92,7 @@ export function buildInitialGrokProviderSnapshot(
enabled: true,
checkedAt,
models,
slashCommands: GROK_SLASH_COMMANDS,
probe: {
installed: true,
version: null,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -302,6 +320,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
enabled: grokSettings.enabled,
checkedAt,
models,
slashCommands: GROK_SLASH_COMMANDS,
probe: {
installed: true,
version,
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/provider/acp/XAiAcpExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions apps/server/src/provider/acp/XAiAcpExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading