mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(opencode): add plan mode, reasoning effort, and status telemetry (#688)
* feat(opencode): support plan mode * feat(opencode): support reasoning effort * feat(opencode): surface context usage in web Bridge OpenCode ACP usage updates into the existing token-count pipeline so the web status bar can show live context and cache information without a separate UI path. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): restrict plan mode to remote, rollback reasoning effort on failure - Block local OpenCode plan startup (tools not enforced in local path) - Allow remote OpenCode plan only (ACP permission handler denies tools) - Guard web /permission-mode endpoint for local OpenCode plan sessions - Rollback session reasoning effort when OpenCode rejects set_config_option - Wire rollback callback through opencodeLoop to runOpencode closure - Add tests: local plan rejected, remote plan allowed, web guard, effort rollback * fix(web): auto-retry OpenCode models query to populate model selector without refresh - Retry early failures (RPC may still be registering on new sessions) - Poll briefly until availableModels is non-empty - Stop polling once model options are discovered - Add tests for retry/poll/stop policy * fix(opencode): cap model discovery polling --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -155,6 +155,45 @@ describe('AcpSdkBackend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('captures model metadata from configOptions when models block is missing', async () => {
|
||||
const backend = new AcpSdkBackend({ command: 'opencode' });
|
||||
const backendInternal = backend as unknown as {
|
||||
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
|
||||
};
|
||||
backendInternal.transport = {
|
||||
sendRequest: async (method) => {
|
||||
if (method === 'session/new') {
|
||||
return {
|
||||
sessionId: 'opencode-session-config-options',
|
||||
configOptions: [
|
||||
{
|
||||
id: 'model',
|
||||
category: 'model',
|
||||
currentValue: 'opencode/big-pickle',
|
||||
options: [
|
||||
{ value: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
|
||||
{ value: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
close: async () => {}
|
||||
};
|
||||
|
||||
const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
|
||||
|
||||
expect(backend.getSessionModelsMetadata(sessionId)).toEqual({
|
||||
availableModels: [
|
||||
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
|
||||
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
|
||||
],
|
||||
currentModelId: 'opencode/big-pickle'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined session metadata when session/new omits models', async () => {
|
||||
const backend = new AcpSdkBackend({ command: 'gemini' });
|
||||
const backendInternal = backend as unknown as {
|
||||
@@ -213,6 +252,67 @@ describe('AcpSdkBackend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('captures and sets OpenCode thought-level config option', async () => {
|
||||
const backend = new AcpSdkBackend({ command: 'opencode' });
|
||||
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: 's1',
|
||||
configOptions: [{
|
||||
id: 'effort',
|
||||
name: 'Effort',
|
||||
category: 'thought_level',
|
||||
type: 'select',
|
||||
currentValue: 'low',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'high', name: 'High' }
|
||||
]
|
||||
}]
|
||||
};
|
||||
}
|
||||
if (method === 'session/set_config_option') {
|
||||
return {
|
||||
configOptions: [{
|
||||
id: 'effort',
|
||||
category: 'thought_level',
|
||||
currentValue: 'high',
|
||||
options: [{ value: 'high', name: 'High' }]
|
||||
}]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
close: async () => {}
|
||||
};
|
||||
|
||||
await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });
|
||||
expect(backend.getThoughtLevelConfigOption('s1')).toMatchObject({
|
||||
id: 'effort',
|
||||
currentValue: 'low',
|
||||
options: [{ value: 'low', name: 'Low' }, { value: 'high', name: 'High' }]
|
||||
});
|
||||
|
||||
await backend.setConfigOption('s1', 'effort', 'high');
|
||||
|
||||
expect(calls).toContainEqual({
|
||||
method: 'session/set_config_option',
|
||||
params: { sessionId: 's1', configId: 'effort', value: 'high' }
|
||||
});
|
||||
expect(backend.getThoughtLevelConfigOption('s1')).toMatchObject({
|
||||
id: 'effort',
|
||||
currentValue: 'high'
|
||||
});
|
||||
});
|
||||
|
||||
it('emits turn_complete after trailing tool updates from the same turn', async () => {
|
||||
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
|
||||
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
|
||||
@@ -284,4 +384,65 @@ describe('AcpSdkBackend', () => {
|
||||
'turn_complete'
|
||||
]);
|
||||
});
|
||||
|
||||
it('combines OpenCode usage_update and prompt usage into a usage message', async () => {
|
||||
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
|
||||
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
|
||||
backendStatics.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 1;
|
||||
backendStatics.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 50;
|
||||
|
||||
const backend = new AcpSdkBackend({ command: 'opencode' });
|
||||
const backendInternal = backend as unknown as {
|
||||
transport: {
|
||||
sendRequest: (...args: unknown[]) => Promise<unknown>;
|
||||
close: () => Promise<void>;
|
||||
} | null;
|
||||
handleSessionUpdate: (params: unknown) => void;
|
||||
};
|
||||
|
||||
const messages: AgentMessage[] = [];
|
||||
backendInternal.transport = {
|
||||
sendRequest: async () => {
|
||||
setTimeout(() => {
|
||||
backendInternal.handleSessionUpdate({
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'usage_update',
|
||||
used: 13_879,
|
||||
size: 65_536,
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
||||
await sleep(5);
|
||||
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
usage: {
|
||||
totalTokens: 13_892,
|
||||
inputTokens: 8_119,
|
||||
outputTokens: 2,
|
||||
thoughtTokens: 11,
|
||||
cachedReadTokens: 5_760
|
||||
}
|
||||
};
|
||||
},
|
||||
close: async () => {}
|
||||
};
|
||||
|
||||
await backend.prompt('session-1', [{ type: 'text', text: 'hello' }], (message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
expect(messages).toContainEqual({
|
||||
type: 'usage',
|
||||
inputTokens: 8_119,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 5_760,
|
||||
thoughtTokens: 11,
|
||||
totalTokens: 13_892,
|
||||
contextTokens: 13_879,
|
||||
contextWindow: 65_536
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentBackend, AgentMessage, AgentSessionConfig, PermissionRequest,
|
||||
import { asString, isObject } from '@hapi/protocol';
|
||||
import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport';
|
||||
import { AcpMessageHandler } from './AcpMessageHandler';
|
||||
import { ACP_SESSION_UPDATE_TYPES } from './constants';
|
||||
import { logger } from '@/ui/logger';
|
||||
import { withRetry } from '@/utils/time';
|
||||
import packageJson from '../../../../package.json';
|
||||
@@ -11,6 +12,19 @@ type PendingPermission = {
|
||||
resolve: (result: { outcome: { outcome: string; optionId?: string } }) => void;
|
||||
};
|
||||
|
||||
type AcpPromptUsage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
};
|
||||
|
||||
type AcpUsageUpdate = {
|
||||
contextTokens: number | undefined;
|
||||
contextWindow: number | undefined;
|
||||
};
|
||||
|
||||
export type AcpModelDescriptor = {
|
||||
modelId: string;
|
||||
name?: string;
|
||||
@@ -21,17 +35,26 @@ export type AcpSessionModelsMetadata = {
|
||||
currentModelId: string | null;
|
||||
};
|
||||
|
||||
export type AcpConfigOptionDescriptor = {
|
||||
id: string;
|
||||
category?: string;
|
||||
currentValue?: string;
|
||||
options: Array<{ value: string; name?: string }>;
|
||||
};
|
||||
|
||||
export class AcpSdkBackend implements AgentBackend {
|
||||
private transport: AcpStdioTransport | null = null;
|
||||
private permissionHandler: ((request: PermissionRequest) => void) | null = null;
|
||||
private stderrErrorHandler: ((error: AcpStderrError) => void) | null = null;
|
||||
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
||||
private readonly sessionModelsMetadata = new Map<string, AcpSessionModelsMetadata>();
|
||||
private readonly sessionConfigOptions = new Map<string, AcpConfigOptionDescriptor[]>();
|
||||
private messageHandler: AcpMessageHandler | null = null;
|
||||
private activeSessionId: string | null = null;
|
||||
private isProcessingMessage = false;
|
||||
private responseCompleteResolvers: Array<() => void> = [];
|
||||
private lastSessionUpdateAt = 0;
|
||||
private latestUsageUpdate: AcpUsageUpdate | null = null;
|
||||
|
||||
/** Retry configuration for ACP initialization */
|
||||
private static readonly INIT_RETRY_OPTIONS = {
|
||||
@@ -120,7 +143,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
}
|
||||
|
||||
this.activeSessionId = sessionId;
|
||||
this.captureSessionModelsMetadata(sessionId, response);
|
||||
this.captureSessionMetadata(sessionId, response);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
@@ -146,7 +169,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
const loadedSessionId = isObject(response) ? asString(response.sessionId) : null;
|
||||
const sessionId = loadedSessionId ?? config.sessionId;
|
||||
this.activeSessionId = sessionId;
|
||||
this.captureSessionModelsMetadata(sessionId, response);
|
||||
this.captureSessionMetadata(sessionId, response);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
@@ -183,10 +206,29 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
} else {
|
||||
// For other flavors (e.g. Gemini), if the response carries metadata,
|
||||
// capture it. Missing fields are silently ignored.
|
||||
this.captureSessionModelsMetadata(sessionId, response);
|
||||
this.captureSessionMetadata(sessionId, response);
|
||||
}
|
||||
}
|
||||
|
||||
async setConfigOption(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
if (!this.transport) {
|
||||
throw new Error('ACP transport not initialized');
|
||||
}
|
||||
|
||||
await this.waitForResponseComplete();
|
||||
|
||||
const response = await this.transport.sendRequest('session/set_config_option', {
|
||||
sessionId,
|
||||
configId,
|
||||
value
|
||||
});
|
||||
this.captureSessionMetadata(sessionId, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the per-session models metadata captured from session/new (or
|
||||
* session/load, or session/set_model). Returns undefined if the agent did
|
||||
@@ -196,6 +238,10 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
return this.sessionModelsMetadata.get(sessionId);
|
||||
}
|
||||
|
||||
getThoughtLevelConfigOption(sessionId: string): AcpConfigOptionDescriptor | undefined {
|
||||
return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === 'thought_level');
|
||||
}
|
||||
|
||||
async prompt(
|
||||
sessionId: string,
|
||||
content: PromptContent[],
|
||||
@@ -219,7 +265,9 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
this.messageHandler = new AcpMessageHandler(onUpdate);
|
||||
this.isProcessingMessage = true;
|
||||
this.lastSessionUpdateAt = Date.now();
|
||||
this.latestUsageUpdate = null;
|
||||
let stopReason: string | null = null;
|
||||
let promptUsage: AcpPromptUsage | null = null;
|
||||
|
||||
try {
|
||||
// No timeout for prompt requests - they can run for extended periods
|
||||
@@ -230,6 +278,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
}, { timeoutMs: Infinity });
|
||||
|
||||
stopReason = isObject(response) ? asString(response.stopReason) : null;
|
||||
promptUsage = this.extractPromptUsage(response);
|
||||
} finally {
|
||||
await this.waitForSessionUpdateQuiet(
|
||||
AcpSdkBackend.UPDATE_QUIET_PERIOD_MS,
|
||||
@@ -237,6 +286,19 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
);
|
||||
this.messageHandler?.drainBuffers();
|
||||
try {
|
||||
const latestUsageUpdate = this.readLatestUsageUpdate();
|
||||
if (promptUsage) {
|
||||
onUpdate({
|
||||
type: 'usage',
|
||||
inputTokens: promptUsage.inputTokens,
|
||||
outputTokens: promptUsage.outputTokens,
|
||||
totalTokens: promptUsage.totalTokens,
|
||||
thoughtTokens: promptUsage.thoughtTokens,
|
||||
cacheReadTokens: promptUsage.cacheReadTokens,
|
||||
contextTokens: latestUsageUpdate ? latestUsageUpdate.contextTokens : undefined,
|
||||
contextWindow: latestUsageUpdate ? latestUsageUpdate.contextWindow : undefined
|
||||
});
|
||||
}
|
||||
if (stopReason) {
|
||||
onUpdate({ type: 'turn_complete', stopReason });
|
||||
}
|
||||
@@ -336,9 +398,26 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
}
|
||||
this.lastSessionUpdateAt = Date.now();
|
||||
const update = params.update;
|
||||
this.captureUsageUpdate(update);
|
||||
this.messageHandler?.handleUpdate(update);
|
||||
}
|
||||
|
||||
private captureUsageUpdate(update: unknown): void {
|
||||
if (!isObject(update)) return;
|
||||
if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.usageUpdate) return;
|
||||
|
||||
const contextTokens = this.asFiniteNumber(update.used);
|
||||
const contextWindow = this.asFiniteNumber(update.size);
|
||||
this.latestUsageUpdate = {
|
||||
contextTokens: contextTokens ?? undefined,
|
||||
contextWindow: contextWindow ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
private readLatestUsageUpdate(): AcpUsageUpdate | null {
|
||||
return this.latestUsageUpdate;
|
||||
}
|
||||
|
||||
private async waitForSessionUpdateQuiet(quietMs: number, timeoutMs: number): Promise<void> {
|
||||
if (quietMs <= 0 || timeoutMs <= 0) {
|
||||
return;
|
||||
@@ -434,6 +513,64 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
});
|
||||
}
|
||||
|
||||
private extractPromptUsage(response: unknown): AcpPromptUsage | null {
|
||||
if (!isObject(response) || !isObject(response.usage)) return null;
|
||||
const usage = response.usage;
|
||||
const inputTokens = this.asFiniteNumber(usage.inputTokens ?? usage.input_tokens);
|
||||
const outputTokens = this.asFiniteNumber(usage.outputTokens ?? usage.output_tokens);
|
||||
if (inputTokens === null || outputTokens === null) return null;
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: this.asFiniteNumber(usage.totalTokens ?? usage.total_tokens) ?? undefined,
|
||||
thoughtTokens: this.asFiniteNumber(usage.thoughtTokens ?? usage.thought_tokens) ?? undefined,
|
||||
cacheReadTokens: this.asFiniteNumber(
|
||||
usage.cachedReadTokens
|
||||
?? usage.cached_read_tokens
|
||||
?? usage.cachedInputTokens
|
||||
?? usage.cached_input_tokens
|
||||
) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
private asFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
|
||||
private captureSessionMetadata(sessionId: string, response: unknown): void {
|
||||
this.captureSessionModelsMetadata(sessionId, response);
|
||||
this.captureSessionConfigOptions(sessionId, response);
|
||||
}
|
||||
|
||||
private captureSessionConfigOptions(sessionId: string, response: unknown): void {
|
||||
if (!isObject(response) || !Array.isArray(response.configOptions)) return;
|
||||
|
||||
const options = response.configOptions
|
||||
.filter((entry): entry is Record<string, unknown> => isObject(entry))
|
||||
.map((entry): AcpConfigOptionDescriptor | null => {
|
||||
const id = asString(entry.id);
|
||||
if (!id) return null;
|
||||
const rawOptions = Array.isArray(entry.options) ? entry.options : [];
|
||||
return {
|
||||
id,
|
||||
category: asString(entry.category) ?? undefined,
|
||||
currentValue: asString(entry.currentValue) ?? undefined,
|
||||
options: rawOptions
|
||||
.filter((option): option is Record<string, unknown> => isObject(option))
|
||||
.map((option) => ({
|
||||
value: asString(option.value) ?? '',
|
||||
name: asString(option.name) ?? undefined
|
||||
}))
|
||||
.filter((option) => option.value.length > 0)
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is AcpConfigOptionDescriptor => entry !== null);
|
||||
|
||||
this.sessionConfigOptions.set(sessionId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `availableModels` and `currentModelId` from an ACP response and
|
||||
* store them keyed by sessionId. Both top-level and nested-under-`models`
|
||||
@@ -442,6 +579,24 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
* expose model metadata (e.g. current Gemini ACP build) simply leave the
|
||||
* cache untouched.
|
||||
*/
|
||||
private extractModelConfigOption(response: Record<string, unknown>): {
|
||||
currentValue: string | null;
|
||||
options: unknown[];
|
||||
} | null {
|
||||
if (!Array.isArray(response.configOptions)) return null;
|
||||
|
||||
for (const entry of response.configOptions) {
|
||||
if (!isObject(entry)) continue;
|
||||
if (asString(entry.category) !== 'model') continue;
|
||||
return {
|
||||
currentValue: asString(entry.currentValue),
|
||||
options: Array.isArray(entry.options) ? entry.options : []
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private captureSessionModelsMetadata(sessionId: string, response: unknown): void {
|
||||
if (!isObject(response)) return;
|
||||
|
||||
@@ -451,16 +606,17 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
const nestedList = nested?.availableModels;
|
||||
const nestedCurrent = nested?.currentModelId;
|
||||
|
||||
const configModelOption = this.extractModelConfigOption(response);
|
||||
const rawModels = Array.isArray(directList)
|
||||
? directList
|
||||
: Array.isArray(nestedList)
|
||||
? nestedList
|
||||
: null;
|
||||
: configModelOption?.options ?? null;
|
||||
const rawCurrent = typeof directCurrent === 'string'
|
||||
? directCurrent
|
||||
: typeof nestedCurrent === 'string'
|
||||
? nestedCurrent
|
||||
: null;
|
||||
: configModelOption?.currentValue ?? null;
|
||||
|
||||
if (rawModels === null && rawCurrent === null) {
|
||||
return;
|
||||
@@ -470,7 +626,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
if (Array.isArray(rawModels)) {
|
||||
for (const entry of rawModels) {
|
||||
if (!isObject(entry)) continue;
|
||||
const modelId = asString(entry.modelId);
|
||||
const modelId = asString(entry.modelId) ?? asString(entry.value);
|
||||
if (!modelId) continue;
|
||||
const name = asString(entry.name) ?? undefined;
|
||||
availableModels.push(name ? { modelId, name } : { modelId });
|
||||
|
||||
@@ -3,5 +3,6 @@ export const ACP_SESSION_UPDATE_TYPES = {
|
||||
agentThoughtChunk: 'agent_thought_chunk',
|
||||
toolCall: 'tool_call',
|
||||
toolCallUpdate: 'tool_call_update',
|
||||
plan: 'plan'
|
||||
plan: 'plan',
|
||||
usageUpdate: 'usage_update'
|
||||
} as const;
|
||||
|
||||
@@ -49,4 +49,32 @@ describe('convertAgentMessage', () => {
|
||||
id: 'reasoning-stream-1'
|
||||
});
|
||||
});
|
||||
|
||||
it('converts usage messages into token_count payloads', () => {
|
||||
const converted = convertAgentMessage({
|
||||
type: 'usage',
|
||||
inputTokens: 8_119,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 5_760,
|
||||
thoughtTokens: 11,
|
||||
totalTokens: 13_892,
|
||||
contextTokens: 13_879,
|
||||
contextWindow: 65_536
|
||||
});
|
||||
|
||||
expect(converted).toEqual({
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: 8119,
|
||||
outputTokens: 2,
|
||||
cachedInputTokens: 5760,
|
||||
thoughtTokens: 11,
|
||||
totalTokens: 13892
|
||||
},
|
||||
contextTokens: 13879,
|
||||
modelContextWindow: 65536
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,20 @@ import type { AgentMessage, PlanItem } from './types';
|
||||
export type CodexMessage =
|
||||
| { type: 'message'; message: string }
|
||||
| { type: 'reasoning'; message: string; id: string }
|
||||
| {
|
||||
type: 'token_count';
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
};
|
||||
contextTokens?: number;
|
||||
modelContextWindow?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'tool-call';
|
||||
name: string;
|
||||
@@ -29,6 +43,21 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null
|
||||
// the wire-level CodexMessage uses `message` to match the
|
||||
// existing reasoning format emitted by the Codex path.
|
||||
return { type: 'reasoning', message: message.text, id: message.id ?? randomUUID() };
|
||||
case 'usage':
|
||||
return {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: message.inputTokens,
|
||||
outputTokens: message.outputTokens,
|
||||
totalTokens: message.totalTokens,
|
||||
thoughtTokens: message.thoughtTokens,
|
||||
cachedInputTokens: message.cacheReadTokens
|
||||
},
|
||||
contextTokens: message.contextTokens,
|
||||
modelContextWindow: message.contextWindow
|
||||
}
|
||||
};
|
||||
case 'tool_call':
|
||||
return {
|
||||
type: 'tool-call',
|
||||
|
||||
@@ -23,6 +23,10 @@ describe('sessionConfigRpc', () => {
|
||||
expect(() => resolveSessionConfigPermissionMode('bypassPermissions', 'gemini')).toThrow('Invalid permission mode')
|
||||
})
|
||||
|
||||
it('accepts OpenCode plan permission mode', () => {
|
||||
expect(resolveSessionConfigPermissionMode('plan', 'opencode')).toBe('plan')
|
||||
})
|
||||
|
||||
it('accepts null model for agents that support model config', () => {
|
||||
expect(resolveNullableSessionModel(null)).toBeNull()
|
||||
})
|
||||
@@ -68,6 +72,25 @@ describe('sessionConfigRpc', () => {
|
||||
expect(onApply).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('applies nullable model reasoning effort when supported', async () => {
|
||||
const harness = createRpcHarness()
|
||||
const onApply = vi.fn()
|
||||
|
||||
registerSessionConfigRpc({
|
||||
rpcHandlerManager: harness.rpcHandlerManager,
|
||||
flavor: 'opencode',
|
||||
modelReasoningEffortMode: 'nullable',
|
||||
onApply
|
||||
})
|
||||
|
||||
const result = await harness.getHandler()({ modelReasoningEffort: 'high' }) as { applied: Record<string, unknown> }
|
||||
|
||||
expect(result.applied.modelReasoningEffort).toBe('high')
|
||||
expect(onApply).toHaveBeenCalledWith({ modelReasoningEffort: 'high' })
|
||||
})
|
||||
|
||||
it('rejects model config for agents configured to reject model changes', async () => {
|
||||
const harness = createRpcHarness()
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
|
||||
type SessionConfigState<TPermissionMode extends PermissionMode = PermissionMode> = {
|
||||
permissionMode?: TPermissionMode
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
}
|
||||
|
||||
type RegisterSessionConfigRpcOptions<TPermissionMode extends PermissionMode = PermissionMode> = {
|
||||
rpcHandlerManager: RpcHandlerManager
|
||||
flavor: AgentFlavor
|
||||
modelMode?: 'nullable' | 'ignore' | 'reject'
|
||||
modelReasoningEffortMode?: 'nullable' | 'ignore' | 'reject'
|
||||
appliedFallback?: () => Record<string, unknown>
|
||||
onApply: (config: SessionConfigState<TPermissionMode>) => void
|
||||
onAfterApply?: () => void
|
||||
@@ -43,6 +45,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
|
||||
rpcHandlerManager,
|
||||
flavor,
|
||||
modelMode = 'reject',
|
||||
modelReasoningEffortMode = 'reject',
|
||||
appliedFallback,
|
||||
onApply,
|
||||
onAfterApply
|
||||
@@ -52,7 +55,7 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
|
||||
throw new Error('Invalid session config payload')
|
||||
}
|
||||
|
||||
const config = payload as { permissionMode?: unknown; model?: unknown }
|
||||
const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown }
|
||||
const applied: Record<string, unknown> = {}
|
||||
const next: SessionConfigState<TPermissionMode> = {}
|
||||
|
||||
@@ -71,6 +74,17 @@ export function registerSessionConfigRpc<TPermissionMode extends PermissionMode>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (config.modelReasoningEffort !== undefined) {
|
||||
if (modelReasoningEffortMode === 'reject') {
|
||||
throw new Error('Invalid model reasoning effort')
|
||||
}
|
||||
if (modelReasoningEffortMode === 'nullable') {
|
||||
next.modelReasoningEffort = resolveNullableSessionModel(config.modelReasoningEffort)
|
||||
applied.modelReasoningEffort = next.modelReasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
onApply(next)
|
||||
onAfterApply?.()
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@ export type AgentMessage =
|
||||
| { type: 'reasoning'; text: string; id?: string; live?: boolean }
|
||||
| { type: 'tool_call'; id: string; name: string; input: unknown; status: 'pending' | 'in_progress' | 'completed' | 'failed' }
|
||||
| { type: 'tool_result'; id: string; output: unknown; status: 'completed' | 'failed' }
|
||||
| {
|
||||
type: 'usage';
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
}
|
||||
| { type: 'plan'; items: PlanItem[] }
|
||||
| { type: 'turn_complete'; stopReason: string }
|
||||
| { type: 'error'; message: string };
|
||||
@@ -68,11 +78,20 @@ export type AgentSessionModelsMetadata = {
|
||||
currentModelId: string | null;
|
||||
};
|
||||
|
||||
export type AgentSessionConfigOptionDescriptor = {
|
||||
id: string;
|
||||
category?: string;
|
||||
currentValue?: string;
|
||||
options: Array<{ value: string; name?: string }>;
|
||||
};
|
||||
|
||||
export interface AgentBackend {
|
||||
initialize(): Promise<void>;
|
||||
newSession(config: AgentSessionConfig): Promise<string>;
|
||||
setModel?(sessionId: string, modelId: string, opts?: { flavor?: AgentFlavor }): Promise<void>;
|
||||
setConfigOption?(sessionId: string, configId: string, value: string): Promise<void>;
|
||||
getSessionModelsMetadata?(sessionId: string): AgentSessionModelsMetadata | undefined;
|
||||
getThoughtLevelConfigOption?(sessionId: string): AgentSessionConfigOptionDescriptor | undefined;
|
||||
prompt(sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void): Promise<void>;
|
||||
cancelPrompt(sessionId: string): Promise<void>;
|
||||
respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user