feat: add Grok Build support (#1030)

* test: define Grok Build integration behavior

* feat: add Grok Build agent integration

* test: cover Grok permissions and resume paths

* docs: add Grok Build setup guide

* fix: scope Grok ACP discovery to session cwd

* fix: align Grok permission UI semantics

* docs: clarify Grok runner setup

* test: require Grok create model and effort options

* feat: add Grok create model and effort pickers

* test: define Grok runtime parity behavior

* feat: add Grok runtime ACP controls and discovery

* fix: tighten Grok runtime controls

* fix: suppress nonfatal Grok title quota errors

* feat: support Grok Auto permission mode

* feat: forward ACP native session titles for Grok

* fix: guard Grok Windows shell arguments
This commit is contained in:
SSU-WEI HUANG
2026-07-13 08:41:30 +08:00
committed by GitHub
parent de07643828
commit b9eed7c071
92 changed files with 3796 additions and 63 deletions
@@ -187,6 +187,79 @@ describe('AcpSdkBackend', () => {
});
});
it('captures Grok reasoning efforts from x.ai session metadata and switches with set_mode', async () => {
const backend = new AcpSdkBackend({ command: 'grok' });
const calls: Array<{ method: string; params: unknown }> = [];
const backendInternal = backend as unknown as {
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
};
backendInternal.transport = {
sendRequest: async (method, params) => {
calls.push({ method, params });
if (method === 'session/new') {
return {
sessionId: 'grok-session-1',
models: {
currentModelId: 'grok-4.5',
availableModels: [{
modelId: 'grok-4.5',
name: 'Grok 4.5',
_meta: {
reasoningEfforts: [
{ value: 'high', label: 'High Effort', default: true },
{ value: 'low', label: 'Low Effort', default: false }
]
}
}]
},
_meta: {
availableCommands: [{ name: 'auto' }],
'x.ai/sessionConfig': {
options: [
{ id: 'high', category: 'mode', label: 'High Effort', selected: false },
{ id: 'low', category: 'mode', label: 'Low Effort', selected: true }
]
}
}
};
}
if (method === 'session/set_mode') return { meta: null };
return null;
},
close: async () => {}
};
const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
expect(backend.getSessionModelsMetadata(sessionId)).toEqual({
availableModels: [{
modelId: 'grok-4.5',
name: 'Grok 4.5',
reasoningEfforts: [
{ value: 'high', name: 'High Effort', isDefault: true },
{ value: 'low', name: 'Low Effort', isDefault: false }
]
}],
currentModelId: 'grok-4.5'
});
expect(backend.getThoughtLevelConfigOption(sessionId)).toMatchObject({
currentValue: 'low',
options: [
{ value: 'high', name: 'High Effort' },
{ value: 'low', name: 'Low Effort' }
]
});
expect(backend.hasAvailableCommand(sessionId, 'auto')).toBe(true);
await backend.setMode(sessionId, 'high');
expect(calls).toContainEqual({
method: 'session/set_mode',
params: { sessionId, modeId: 'high' }
});
expect(backend.getThoughtLevelConfigOption(sessionId)?.currentValue).toBe('high');
});
it('merges configOptions model variants into availableModels when both are present', async () => {
const backend = new AcpSdkBackend({ command: 'agent' });
const backendInternal = backend as unknown as {
@@ -755,6 +828,52 @@ describe('AcpSdkBackend', () => {
expect(realtimeUsage.map((m) => m.contextTokens)).toEqual([1_000, 2_500]);
});
it('forwards title changes from session_info_update', () => {
const backend = new AcpSdkBackend({ command: 'agent' });
const updates: Array<{ title?: string | null }> = [];
backend.setSessionInfoUpdateListener((update) => updates.push(update));
const backendInternal = backend as unknown as {
activeSessionId: string | null;
handleSessionUpdate: (params: unknown) => void;
};
backendInternal.activeSessionId = 'session-1';
backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: 'Native ACP title'
}
});
backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: null
}
});
backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: 123
}
});
backendInternal.handleSessionUpdate({
sessionId: 'other-session',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: 'Wrong session'
}
});
expect(updates).toEqual([
{ title: 'Native ACP title' },
{ title: null }
]);
});
it('emits a context-only usage on finalize when the prompt response carries no usage', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
+138 -10
View File
@@ -25,9 +25,14 @@ type AcpUsageUpdate = {
contextWindow: number | undefined;
};
export type AcpSessionInfoUpdate = {
title?: string | null;
};
export type AcpModelDescriptor = {
modelId: string;
name?: string;
reasoningEfforts?: Array<{ value: string; name?: string; isDefault?: boolean }>;
};
export type AcpSessionModelsMetadata = {
@@ -59,6 +64,9 @@ export class AcpSdkBackend implements AgentBackend {
private readonly pendingPermissions = new Map<string, PendingPermission>();
private readonly sessionModelsMetadata = new Map<string, AcpSessionModelsMetadata>();
private readonly sessionConfigOptions = new Map<string, AcpConfigOptionDescriptor[]>();
private readonly initialAvailableCommands = new Set<string>();
private readonly sessionAvailableCommands = new Map<string, Set<string>>();
private autoPermissionModeEnabled: boolean | null = null;
private messageHandler: AcpMessageHandler | null = null;
private activeSessionId: string | null = null;
private initializeResult: AcpInitializeResult | null = null;
@@ -69,6 +77,7 @@ export class AcpSdkBackend implements AgentBackend {
private latestUsageUpdate: AcpUsageUpdate | null = null;
private promptUsageCallback: ((msg: AgentMessage) => void) | null = null;
private usageUpdateListener: ((msg: AgentMessage) => void) | null = null;
private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null;
private lastForwardedUsageUpdate: AcpUsageUpdate | null = null;
/** Retry configuration for ACP initialization */
@@ -120,6 +129,12 @@ export class AcpSdkBackend implements AgentBackend {
this.transport.onNotification((method, params) => {
if (method === 'session/update') {
this.handleSessionUpdate(params);
} else if (
method === '_x.ai/settings/update'
&& isObject(params)
&& 'auto_permission_mode_enabled' in params
) {
this.autoPermissionModeEnabled = params.auto_permission_mode_enabled === true;
}
});
@@ -161,6 +176,8 @@ export class AcpSdkBackend implements AgentBackend {
throw new Error('Invalid initialize response from ACP agent');
}
this.captureAvailableCommands(null, response);
this.initializeResult = {
protocolVersion: response.protocolVersion,
authMethods: Array.isArray(response.authMethods)
@@ -238,6 +255,7 @@ export class AcpSdkBackend implements AgentBackend {
try {
await this.transport.sendRequest('session/set_mode', { sessionId, modeId });
this.setModeSupported = true;
this.updateThoughtLevelCurrentValue(sessionId, modeId);
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -335,7 +353,7 @@ export class AcpSdkBackend implements AgentBackend {
modelId
});
if (opts?.flavor === 'opencode') {
if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') {
// OpenCode's set_model response only carries an opaque `_meta` block,
// not `availableModels`/`currentModelId`. Optimistically update the
// cached currentModelId (the call succeeded, so the agent has switched)
@@ -380,11 +398,24 @@ export class AcpSdkBackend implements AgentBackend {
return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === 'thought_level');
}
hasAvailableCommand(sessionId: string, command: string): boolean {
if (command === 'auto' && this.autoPermissionModeEnabled === true) {
return true;
}
return this.sessionAvailableCommands.get(sessionId)?.has(command)
?? this.initialAvailableCommands.has(command);
}
/** Forwards ACP `usage_update` to the web status bar when no prompt is active (e.g. session resume). */
setUsageUpdateListener(listener: ((msg: AgentMessage) => void) | null): void {
this.usageUpdateListener = listener;
}
/** Forwards ACP `session_info_update` metadata independently of prompt turns. */
setSessionInfoUpdateListener(listener: ((update: AcpSessionInfoUpdate) => void) | null): void {
this.sessionInfoUpdateListener = listener;
}
async prompt(
sessionId: string,
content: PromptContent[],
@@ -553,6 +584,9 @@ export class AcpSdkBackend implements AgentBackend {
this.activeSessionId = null;
this.isProcessingMessage = false;
this.sessionModelsMetadata.clear();
this.initialAvailableCommands.clear();
this.sessionAvailableCommands.clear();
this.autoPermissionModeEnabled = null;
this.notifyResponseComplete();
await this.transport.close();
this.transport = null;
@@ -566,10 +600,24 @@ export class AcpSdkBackend implements AgentBackend {
}
this.lastSessionUpdateAt = Date.now();
const update = params.update;
if (sessionId) {
this.captureAvailableCommands(sessionId, update);
}
this.captureSessionInfoUpdate(update);
this.captureUsageUpdate(update);
this.messageHandler?.handleUpdate(update);
}
private captureSessionInfoUpdate(update: unknown): void {
if (!isObject(update)) return;
if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate) return;
if (!Object.prototype.hasOwnProperty.call(update, 'title')) return;
const title = update.title;
if (typeof title !== 'string' && title !== null) return;
this.sessionInfoUpdateListener?.({ title });
}
private captureUsageUpdate(update: unknown): void {
if (!isObject(update)) return;
@@ -771,6 +819,16 @@ export class AcpSdkBackend implements AgentBackend {
});
}
private updateThoughtLevelCurrentValue(sessionId: string, value: string): void {
const options = this.sessionConfigOptions.get(sessionId);
if (!options) return;
this.sessionConfigOptions.set(sessionId, options.map((option) => (
option.category === 'thought_level'
? { ...option, currentValue: value }
: option
)));
}
/** After a successful model config apply, avoid stale base-only ACP currentValue overwriting cache. */
pinSessionModelWireId(sessionId: string, modelId: string): void {
this.updateCurrentModelOptimistic(sessionId, modelId);
@@ -805,12 +863,41 @@ export class AcpSdkBackend implements AgentBackend {
private captureSessionMetadata(sessionId: string, response: unknown): void {
this.captureSessionModelsMetadata(sessionId, response);
this.captureSessionConfigOptions(sessionId, response);
this.captureAvailableCommands(sessionId, response);
}
private captureAvailableCommands(sessionId: string | null, source: unknown): void {
if (!isObject(source)) return;
const meta = isObject(source._meta) ? source._meta : null;
const rawCommands = Array.isArray(source.availableCommands)
? source.availableCommands
: meta && Array.isArray(meta.availableCommands)
? meta.availableCommands
: null;
if (!rawCommands) return;
const commands = new Set(
rawCommands
.filter((entry): entry is Record<string, unknown> => isObject(entry))
.map((entry) => asString(entry.name) ?? '')
.filter((name) => name.length > 0)
);
if (sessionId) {
this.sessionAvailableCommands.set(sessionId, commands);
return;
}
this.initialAvailableCommands.clear();
for (const command of commands) {
this.initialAvailableCommands.add(command);
}
}
private captureSessionConfigOptions(sessionId: string, response: unknown): void {
if (!isObject(response) || !Array.isArray(response.configOptions)) return;
if (!isObject(response)) return;
const options = response.configOptions
const options = (Array.isArray(response.configOptions) ? response.configOptions : [])
.filter((entry): entry is Record<string, unknown> => isObject(entry))
.map((entry): AcpConfigOptionDescriptor | null => {
const id = asString(entry.id);
@@ -831,7 +918,33 @@ export class AcpSdkBackend implements AgentBackend {
})
.filter((entry): entry is AcpConfigOptionDescriptor => entry !== null);
this.sessionConfigOptions.set(sessionId, options);
const meta = isObject(response._meta) ? response._meta : null;
const xaiConfig = meta && isObject(meta['x.ai/sessionConfig'])
? meta['x.ai/sessionConfig']
: null;
const xaiOptions = xaiConfig && Array.isArray(xaiConfig.options)
? xaiConfig.options.filter((entry): entry is Record<string, unknown> => isObject(entry))
: [];
const effortOptions = xaiOptions
.filter((entry) => asString(entry.category) === 'mode')
.map((entry) => ({
value: asString(entry.id) ?? '',
name: asString(entry.label) ?? undefined,
selected: entry.selected === true
}))
.filter((entry) => entry.value.length > 0);
if (effortOptions.length > 0) {
options.push({
id: 'x.ai/reasoning-effort',
category: 'thought_level',
currentValue: effortOptions.find((entry) => entry.selected)?.value,
options: effortOptions.map(({ value, name }) => ({ value, name }))
});
}
if (options.length > 0) {
this.sessionConfigOptions.set(sessionId, options);
}
}
/**
@@ -886,7 +999,11 @@ export class AcpSdkBackend implements AgentBackend {
}
const byModelId = new Map<string, AcpModelDescriptor>();
const addModel = (modelId: string, name?: string) => {
const addModel = (
modelId: string,
name?: string,
reasoningEfforts?: AcpModelDescriptor['reasoningEfforts']
) => {
const trimmedId = modelId.trim();
if (!trimmedId) return;
const trimmedName = name?.trim();
@@ -895,13 +1012,13 @@ export class AcpSdkBackend implements AgentBackend {
byModelId.set(
trimmedId,
trimmedName && trimmedName !== trimmedId
? { modelId: trimmedId, name: trimmedName }
: { modelId: trimmedId }
? { modelId: trimmedId, name: trimmedName, ...(reasoningEfforts ? { reasoningEfforts } : {}) }
: { modelId: trimmedId, ...(reasoningEfforts ? { reasoningEfforts } : {}) }
);
return;
}
if (!existing.name && trimmedName && trimmedName !== trimmedId) {
byModelId.set(trimmedId, { modelId: trimmedId, name: trimmedName });
byModelId.set(trimmedId, { ...existing, name: trimmedName });
}
};
@@ -910,14 +1027,25 @@ export class AcpSdkBackend implements AgentBackend {
if (!isObject(entry)) continue;
const modelId = asString(entry.modelId) ?? asString(entry.value);
if (!modelId) continue;
addModel(modelId, asString(entry.name) ?? undefined);
const meta = isObject(entry._meta) ? entry._meta : null;
const reasoningEfforts = meta && Array.isArray(meta.reasoningEfforts)
? meta.reasoningEfforts
.filter((effort): effort is Record<string, unknown> => isObject(effort))
.map((effort) => ({
value: asString(effort.value) ?? asString(effort.id) ?? '',
name: asString(effort.label) ?? undefined,
isDefault: effort.default === true
}))
.filter((effort) => effort.value.length > 0)
: undefined;
addModel(modelId, asString(entry.name) ?? undefined, reasoningEfforts);
}
} else {
// Preserve previously-captured availableModels when the response only
// updates currentModelId (e.g. a setModel response from some agents).
const existing = this.sessionModelsMetadata.get(sessionId);
for (const entry of existing?.availableModels ?? []) {
addModel(entry.modelId, entry.name);
addModel(entry.modelId, entry.name, entry.reasoningEfforts);
}
}
+17
View File
@@ -91,6 +91,23 @@ describe('sessionConfigRpc', () => {
expect(onApply).toHaveBeenCalledWith({ modelReasoningEffort: 'high' })
})
it('applies nullable launch effort for Grok runtime switching', async () => {
const harness = createRpcHarness()
const onApply = vi.fn()
registerSessionConfigRpc({
rpcHandlerManager: harness.rpcHandlerManager,
flavor: 'grok',
effortMode: 'nullable',
onApply
})
const result = await harness.getHandler()({ effort: 'low' }) as { applied: Record<string, unknown> }
expect(result.applied.effort).toBe('low')
expect(onApply).toHaveBeenCalledWith({ effort: 'low' })
})
it('rejects model config for agents configured to reject model changes', async () => {
const harness = createRpcHarness()
+14 -1
View File
@@ -8,6 +8,7 @@ type SessionConfigState<TPermissionMode extends PermissionMode = PermissionMode>
permissionMode?: TPermissionMode
model?: string | null
modelReasoningEffort?: string | null
effort?: string | null
}
type RegisterSessionConfigRpcOptions<TPermissionMode extends PermissionMode = PermissionMode> = {
@@ -15,6 +16,7 @@ type RegisterSessionConfigRpcOptions<TPermissionMode extends PermissionMode = Pe
flavor: AgentFlavor
modelMode?: 'nullable' | 'ignore' | 'reject'
modelReasoningEffortMode?: 'nullable' | 'ignore' | 'reject'
effortMode?: 'nullable' | 'ignore' | 'reject'
appliedFallback?: () => Record<string, unknown>
onApply: (config: SessionConfigState<TPermissionMode>) => void
onAfterApply?: () => void
@@ -58,6 +60,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
flavor,
modelMode = 'reject',
modelReasoningEffortMode = 'reject',
effortMode = 'reject',
appliedFallback,
onApply,
onAfterApply
@@ -67,7 +70,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
throw new Error('Invalid session config payload')
}
const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown }
const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown; effort?: unknown }
const applied: Record<string, unknown> = {}
const next: SessionConfigState<TPermissionMode> = {}
@@ -97,6 +100,16 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
}
}
if (config.effort !== undefined) {
if (effortMode === 'reject') {
throw new Error('Invalid effort')
}
if (effortMode === 'nullable') {
next.effort = resolveNullableSessionModel(config.effort)
applied.effort = next.effort
}
}
onApply(next)
onAfterApply?.()
+6 -2
View File
@@ -136,6 +136,7 @@ describe('bootstrapExistingSession', () => {
codexSessionId: 'codex-thread-1',
geminiSessionId: 'gemini-thread-1',
opencodeSessionId: 'opencode-thread-1',
grokSessionId: 'grok-thread-1',
cursorSessionId: 'cursor-thread-1',
cursorSessionProtocol: 'acp',
summary: {
@@ -164,6 +165,7 @@ describe('bootstrapExistingSession', () => {
codexSessionId: 'codex-thread-1',
geminiSessionId: 'gemini-thread-1',
opencodeSessionId: 'opencode-thread-1',
grokSessionId: 'grok-thread-1',
cursorSessionId: 'cursor-thread-1',
cursorSessionProtocol: 'acp',
summary: {
@@ -176,12 +178,14 @@ describe('bootstrapExistingSession', () => {
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
const updateHandler = sessionClient.updateMetadata.mock.calls[0][0]
expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({
codexSessionId: 'codex-thread-1'
codexSessionId: 'codex-thread-1',
grokSessionId: 'grok-thread-1'
}))
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
'hapi-session-1',
expect.objectContaining({
codexSessionId: 'codex-thread-1'
codexSessionId: 'codex-thread-1',
grokSessionId: 'grok-thread-1'
})
)
})
+1
View File
@@ -97,6 +97,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par
if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId
if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId
if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId
if (metadata.grokSessionId !== undefined) preserved.grokSessionId = metadata.grokSessionId
if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId
if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol
if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId
+1
View File
@@ -71,6 +71,7 @@ export type PermissionResponse =
export type AgentSessionModelDescriptor = {
modelId: string;
name?: string;
reasoningEfforts?: Array<{ value: string; name?: string; isDefault?: boolean }>;
};
export type AgentSessionModelsMetadata = {