mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +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>;
|
||||
|
||||
@@ -26,6 +26,13 @@ describe('parseRemoteAgentCommandOptions', () => {
|
||||
], OPENCODE_PERMISSION_MODES).permissionMode).toBe('default')
|
||||
})
|
||||
|
||||
it('accepts OpenCode plan permission mode', () => {
|
||||
expect(parseRemoteAgentCommandOptions([
|
||||
'--permission-mode',
|
||||
'plan'
|
||||
], OPENCODE_PERMISSION_MODES).permissionMode).toBe('plan')
|
||||
})
|
||||
|
||||
it('keeps current unknown-arg behavior by ignoring unrecognized flags', () => {
|
||||
expect(parseRemoteAgentCommandOptions([
|
||||
'--unknown',
|
||||
@@ -49,8 +56,16 @@ describe('parseRemoteAgentCommandOptions', () => {
|
||||
], GEMINI_PERMISSION_MODES)).toThrow('Invalid --permission-mode value')
|
||||
})
|
||||
|
||||
it('parses model reasoning effort', () => {
|
||||
expect(parseRemoteAgentCommandOptions([
|
||||
'--model-reasoning-effort',
|
||||
'high'
|
||||
], OPENCODE_PERMISSION_MODES).modelReasoningEffort).toBe('high')
|
||||
})
|
||||
|
||||
it('requires values for resume and model flags', () => {
|
||||
expect(() => parseRemoteAgentCommandOptions(['--resume'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --resume value')
|
||||
expect(() => parseRemoteAgentCommandOptions(['--model'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model value')
|
||||
expect(() => parseRemoteAgentCommandOptions(['--model-reasoning-effort'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model-reasoning-effort value')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ export type RemoteAgentCommandOptions<TPermissionMode extends PermissionMode> =
|
||||
startingMode?: 'local' | 'remote'
|
||||
permissionMode?: TPermissionMode
|
||||
model?: string
|
||||
modelReasoningEffort?: string
|
||||
resumeSessionId?: string
|
||||
}
|
||||
|
||||
@@ -47,6 +48,12 @@ export function parseRemoteAgentCommandOptions<TPermissionMode extends Permissio
|
||||
throw new Error('Missing --model value')
|
||||
}
|
||||
options.model = model
|
||||
} else if (arg === '--model-reasoning-effort') {
|
||||
const modelReasoningEffort = args[++i]
|
||||
if (!modelReasoningEffort) {
|
||||
throw new Error('Missing --model-reasoning-effort value')
|
||||
}
|
||||
options.modelReasoningEffort = modelReasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,8 @@ class GeminiRemoteLauncher extends RemoteLauncherBase {
|
||||
case 'tool_result':
|
||||
this.messageBuffer.addMessage('Tool result received', 'result');
|
||||
break;
|
||||
case 'usage':
|
||||
break;
|
||||
case 'plan':
|
||||
this.messageBuffer.addMessage('Plan updated', 'status');
|
||||
break;
|
||||
|
||||
@@ -228,6 +228,8 @@ class KimiRemoteLauncher extends RemoteLauncherBase {
|
||||
case 'tool_result':
|
||||
this.messageBuffer.addMessage('Tool result received', 'result');
|
||||
break;
|
||||
case 'usage':
|
||||
break;
|
||||
case 'plan':
|
||||
this.messageBuffer.addMessage('Plan updated', 'status');
|
||||
break;
|
||||
|
||||
@@ -74,16 +74,32 @@ describe('listOpencodeModelsForCwd', () => {
|
||||
expect(closeMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty availableModels when session/new omits the models block', async () => {
|
||||
it('reads availableModels from configOptions when session/new omits the models block', async () => {
|
||||
sendRequestMock
|
||||
.mockResolvedValueOnce({ protocolVersion: 1 })
|
||||
.mockResolvedValueOnce({ sessionId: 'sess-2' })
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: 'sess-2',
|
||||
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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await listOpencodeModelsForCwd('/tmp/proj')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.availableModels).toEqual([])
|
||||
expect(result.currentModelId).toBeNull()
|
||||
expect(result.availableModels).toEqual([
|
||||
{ modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' },
|
||||
{ modelId: 'deepseek/deepseek-chat', name: 'DeepSeek/DeepSeek Chat' }
|
||||
])
|
||||
expect(result.currentModelId).toBe('opencode/big-pickle')
|
||||
})
|
||||
|
||||
it('reads availableModels from top-level fields too (alternate response shape)', async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] {
|
||||
const out: OpencodeModelSummary[] = [];
|
||||
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;
|
||||
out.push(name ? { modelId, name } : { modelId });
|
||||
@@ -33,6 +33,24 @@ function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
function extractModelsFromResponse(response: unknown): {
|
||||
availableModels: OpencodeModelSummary[];
|
||||
currentModelId: string | null;
|
||||
@@ -47,16 +65,17 @@ function extractModelsFromResponse(response: unknown): {
|
||||
const nestedList = nested?.availableModels;
|
||||
const nestedCurrent = nested?.currentModelId;
|
||||
|
||||
const configModelOption = 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;
|
||||
|
||||
return {
|
||||
availableModels: normalizeAvailableModels(rawModels),
|
||||
|
||||
@@ -18,10 +18,12 @@ interface OpencodeLoopOptions {
|
||||
api: ApiClient;
|
||||
permissionMode?: PermissionMode;
|
||||
model?: string;
|
||||
modelReasoningEffort?: string | null;
|
||||
resumeSessionId?: string;
|
||||
hookServer: OpencodeHookServer;
|
||||
hookUrl: string;
|
||||
onSessionReady?: (session: OpencodeSession) => void;
|
||||
onReasoningEffortRollback?: (effort: string | null) => void;
|
||||
}
|
||||
|
||||
export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
|
||||
@@ -40,7 +42,8 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
|
||||
mode: startingMode,
|
||||
startedBy,
|
||||
startingMode,
|
||||
permissionMode: opts.permissionMode ?? 'default'
|
||||
permissionMode: opts.permissionMode ?? 'default',
|
||||
modelReasoningEffort: opts.modelReasoningEffort
|
||||
});
|
||||
|
||||
if (opts.resumeSessionId) {
|
||||
@@ -55,7 +58,9 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
|
||||
hookServer: opts.hookServer,
|
||||
hookUrl: opts.hookUrl
|
||||
}),
|
||||
runRemote: (instance) => opencodeRemoteLauncher(instance),
|
||||
runRemote: (instance) => opencodeRemoteLauncher(instance, {
|
||||
onReasoningEffortRollback: opts.onReasoningEffortRollback
|
||||
}),
|
||||
onSessionReady: opts.onSessionReady
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ import type { OpencodeMode, PermissionMode } from './types';
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
setModelArgs: [] as Array<{ sessionId: string; modelId: string; flavor?: string }>,
|
||||
setConfigOptionArgs: [] as Array<{ sessionId: string; configId: string; value: string }>,
|
||||
promptCount: 0,
|
||||
promptContents: [] as unknown[],
|
||||
events: [] as string[],
|
||||
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>)
|
||||
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>),
|
||||
setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise<void>),
|
||||
thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> }
|
||||
}));
|
||||
|
||||
vi.mock('./utils/opencodeBackend', () => ({
|
||||
@@ -21,7 +25,18 @@ vi.mock('./utils/opencodeBackend', () => ({
|
||||
await harness.setModelImpl(sessionId, modelId);
|
||||
}
|
||||
}),
|
||||
prompt: vi.fn(async () => {
|
||||
setConfigOption: vi.fn(async (sessionId: string, configId: string, value: string) => {
|
||||
harness.events.push(`setConfigOption:${value}`);
|
||||
harness.setConfigOptionArgs.push({ sessionId, configId, value });
|
||||
if (harness.setConfigOptionImpl) {
|
||||
await harness.setConfigOptionImpl(sessionId, configId, value);
|
||||
}
|
||||
if (harness.thoughtLevelOption) {
|
||||
harness.thoughtLevelOption = { ...harness.thoughtLevelOption, currentValue: value };
|
||||
}
|
||||
}),
|
||||
prompt: vi.fn(async (_sessionId: string, content: unknown[]) => {
|
||||
harness.promptContents.push(content);
|
||||
harness.events.push('prompt:start');
|
||||
harness.promptCount++;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
@@ -32,7 +47,8 @@ vi.mock('./utils/opencodeBackend', () => ({
|
||||
onStderrError: vi.fn(),
|
||||
onPermissionRequest: vi.fn(),
|
||||
disconnect: vi.fn(async () => {}),
|
||||
getSessionModelsMetadata: vi.fn(() => undefined)
|
||||
getSessionModelsMetadata: vi.fn(() => undefined),
|
||||
getThoughtLevelConfigOption: vi.fn(() => harness.thoughtLevelOption ?? undefined)
|
||||
}))
|
||||
}));
|
||||
|
||||
@@ -70,6 +86,21 @@ function createMode(model?: string): OpencodeMode {
|
||||
};
|
||||
}
|
||||
|
||||
function createPlanMode(model?: string): OpencodeMode {
|
||||
return {
|
||||
permissionMode: 'plan' as PermissionMode,
|
||||
model
|
||||
};
|
||||
}
|
||||
|
||||
function createModeWithEffort(model: string | undefined, modelReasoningEffort: string | null): OpencodeMode {
|
||||
return {
|
||||
permissionMode: 'default' as PermissionMode,
|
||||
model,
|
||||
modelReasoningEffort
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>) {
|
||||
const queue = new MessageQueue2<OpencodeMode>((mode) => JSON.stringify(mode));
|
||||
items.forEach(({ message, mode }, index) => {
|
||||
@@ -83,6 +114,8 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>
|
||||
|
||||
const sessionEvents: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
const rpcHandlers = new Map<string, (params: unknown) => unknown>();
|
||||
const setModelReasoningEffort = vi.fn();
|
||||
const pushKeepAlive = vi.fn();
|
||||
|
||||
const client = {
|
||||
rpcHandlerManager: {
|
||||
@@ -108,6 +141,8 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>
|
||||
return 'default' as const;
|
||||
},
|
||||
setModel(_model: string | null) {},
|
||||
setModelReasoningEffort,
|
||||
pushKeepAlive,
|
||||
onThinkingChange(thinking: boolean) {
|
||||
session.thinking = thinking;
|
||||
},
|
||||
@@ -121,15 +156,19 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>
|
||||
sendUserMessage(_text: string) {}
|
||||
};
|
||||
|
||||
return { session, sessionEvents, rpcHandlers };
|
||||
return { session, sessionEvents, rpcHandlers, setModelReasoningEffort, pushKeepAlive };
|
||||
}
|
||||
|
||||
describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
afterEach(() => {
|
||||
harness.setModelArgs = [];
|
||||
harness.setConfigOptionArgs = [];
|
||||
harness.promptCount = 0;
|
||||
harness.promptContents = [];
|
||||
harness.events = [];
|
||||
harness.setModelImpl = null;
|
||||
harness.setConfigOptionImpl = null;
|
||||
harness.thoughtLevelOption = null;
|
||||
});
|
||||
|
||||
it('calls setModel with opencode flavor between turns when the queued model differs', async () => {
|
||||
@@ -209,6 +248,77 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
expect(harness.promptCount).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('calls setConfigOption for OpenCode reasoning effort changes', async () => {
|
||||
harness.thoughtLevelOption = {
|
||||
id: 'effort',
|
||||
currentValue: 'low',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'high', name: 'High' }
|
||||
]
|
||||
};
|
||||
const { session } = createSessionStub([
|
||||
{ message: 'first', mode: createModeWithEffort(undefined, 'high') }
|
||||
]);
|
||||
|
||||
await opencodeRemoteLauncher(session as never);
|
||||
|
||||
expect(harness.setConfigOptionArgs).toEqual([
|
||||
{ sessionId: 'acp-session-1', configId: 'effort', value: 'high' }
|
||||
]);
|
||||
expect(harness.promptCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rolls back session reasoning effort when OpenCode rejects the switch', async () => {
|
||||
harness.thoughtLevelOption = {
|
||||
id: 'effort',
|
||||
currentValue: 'low',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'high', name: 'High' }
|
||||
]
|
||||
};
|
||||
harness.setConfigOptionImpl = async () => {
|
||||
throw new Error('Transient backend failure');
|
||||
};
|
||||
const { session, sessionEvents, setModelReasoningEffort, pushKeepAlive } = createSessionStub([
|
||||
{ message: 'first', mode: createModeWithEffort(undefined, 'high') }
|
||||
]);
|
||||
const rollbacks: Array<string | null> = [];
|
||||
|
||||
await opencodeRemoteLauncher(session as never, {
|
||||
onReasoningEffortRollback: (effort) => rollbacks.push(effort)
|
||||
});
|
||||
|
||||
expect(harness.setConfigOptionArgs).toEqual([
|
||||
{ sessionId: 'acp-session-1', configId: 'effort', value: 'high' }
|
||||
]);
|
||||
expect(setModelReasoningEffort).toHaveBeenCalledWith('low');
|
||||
expect(pushKeepAlive).toHaveBeenCalledTimes(1);
|
||||
expect(rollbacks).toEqual(['low']);
|
||||
expect(sessionEvents.some(
|
||||
(event) => event.type === 'message'
|
||||
&& typeof event.message === 'string'
|
||||
&& event.message.includes('Failed to switch reasoning effort')
|
||||
)).toBe(true);
|
||||
expect(harness.promptCount).toBe(1);
|
||||
});
|
||||
|
||||
it('injects plan-mode instructions into plan turns', async () => {
|
||||
const { session } = createSessionStub([
|
||||
{ message: 'design the fix', mode: createPlanMode() }
|
||||
]);
|
||||
|
||||
await opencodeRemoteLauncher(session as never);
|
||||
|
||||
const content = harness.promptContents[0] as Array<{ type: string; text: string }>;
|
||||
expect(content[0]?.text).toContain('You are in plan mode');
|
||||
expect(content[0]?.text).toContain('Do not execute tools');
|
||||
expect(content[0]?.text).toContain('design the fix');
|
||||
});
|
||||
|
||||
it('registers a listOpencodeModels RPC handler that returns the backend cache', async () => {
|
||||
// Override getSessionModelsMetadata for this run only.
|
||||
const fixtureModels = [
|
||||
@@ -251,7 +361,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('listOpencodeModels handler returns empty cache when backend has no metadata', async () => {
|
||||
it('listOpencodeModels handler returns unavailable when backend has no metadata', async () => {
|
||||
const { session, rpcHandlers } = createSessionStub([
|
||||
{ message: 'first', mode: createMode() }
|
||||
]);
|
||||
@@ -261,9 +371,8 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
expect(handler).toBeDefined();
|
||||
const result = await handler!(undefined) as Record<string, unknown>;
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
availableModels: [],
|
||||
currentModelId: null
|
||||
success: false,
|
||||
error: 'OpenCode model metadata is not available'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@ import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'
|
||||
import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase';
|
||||
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
|
||||
import type { OpencodeSession } from './session';
|
||||
import type { PermissionMode } from './types';
|
||||
import type { OpencodeMode, PermissionMode } from './types';
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
|
||||
import { createOpencodeBackend } from './utils/opencodeBackend';
|
||||
import { OpencodePermissionHandler } from './utils/permissionHandler';
|
||||
import { TITLE_INSTRUCTION } from './utils/systemPrompt';
|
||||
import { PLAN_MODE_INSTRUCTION, TITLE_INSTRUCTION } from './utils/systemPrompt';
|
||||
|
||||
type OpencodeRemoteLauncherOptions = {
|
||||
onReasoningEffortRollback?: (effort: string | null) => void;
|
||||
};
|
||||
|
||||
class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
private readonly session: OpencodeSession;
|
||||
@@ -21,9 +25,15 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
private displayPermissionMode: PermissionMode | null = null;
|
||||
private instructionsSent = false;
|
||||
private currentBackendModel: string | null = null;
|
||||
private currentBackendEffort: string | null = null;
|
||||
private defaultBackendEffort: string | null = null;
|
||||
private setModelSupported: boolean | undefined = undefined;
|
||||
private setEffortSupported: boolean | undefined = undefined;
|
||||
|
||||
constructor(session: OpencodeSession) {
|
||||
constructor(
|
||||
session: OpencodeSession,
|
||||
private readonly options: OpencodeRemoteLauncherOptions = {}
|
||||
) {
|
||||
super(process.env.DEBUG ? session.logPath : undefined);
|
||||
this.session = session;
|
||||
}
|
||||
@@ -93,13 +103,16 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
// does not trigger a redundant setModel on the very first turn.
|
||||
const initialMetadata = backend.getSessionModelsMetadata?.(acpSessionId);
|
||||
this.currentBackendModel = initialMetadata?.currentModelId ?? null;
|
||||
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
|
||||
this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null;
|
||||
this.defaultBackendEffort = this.currentBackendEffort;
|
||||
|
||||
// Expose the cached models metadata via per-session RPC so the hub can
|
||||
// forward it to the web UI's model selector without round-tripping ACP.
|
||||
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeModels, async () => {
|
||||
const metadata = backend.getSessionModelsMetadata?.(acpSessionId);
|
||||
if (!metadata) {
|
||||
return { success: true, availableModels: [], currentModelId: null };
|
||||
return { success: false, error: 'OpenCode model metadata is not available' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
@@ -175,13 +188,49 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
}
|
||||
|
||||
const requestedEffort = batch.mode.modelReasoningEffort ?? this.defaultBackendEffort;
|
||||
if (requestedEffort && requestedEffort !== this.currentBackendEffort) {
|
||||
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
|
||||
if (!backend.setConfigOption || !thoughtLevelOption || this.setEffortSupported === false) {
|
||||
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
|
||||
} else {
|
||||
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${requestedEffort}`);
|
||||
try {
|
||||
await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, requestedEffort);
|
||||
this.currentBackendEffort = requestedEffort;
|
||||
this.setEffortSupported = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const methodNotFound = /method not found/i.test(message);
|
||||
if (methodNotFound && this.setEffortSupported === undefined) {
|
||||
this.setEffortSupported = false;
|
||||
logger.warn('[opencode-remote] OpenCode build does not support session/set_config_option; inline effort switching disabled for this session');
|
||||
session.sendSessionEvent({
|
||||
type: 'message',
|
||||
message: 'This OpenCode build does not support inline reasoning effort switching.'
|
||||
});
|
||||
} else {
|
||||
logger.warn('[opencode-remote] Inline effort switch failed', error);
|
||||
session.sendSessionEvent({
|
||||
type: 'message',
|
||||
message: `Failed to switch reasoning effort to ${requestedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.`
|
||||
});
|
||||
}
|
||||
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.applyDisplayMode(batch.mode.permissionMode);
|
||||
messageBuffer.addMessage(batch.message, 'user');
|
||||
|
||||
// Inject title instructions on first prompt
|
||||
let messageText = batch.message;
|
||||
if (batch.mode.permissionMode === 'plan') {
|
||||
messageText = `${PLAN_MODE_INSTRUCTION}\n\n${messageText}`;
|
||||
}
|
||||
if (!this.instructionsSent) {
|
||||
messageText = `${TITLE_INSTRUCTION}\n\n${batch.message}`;
|
||||
messageText = `${TITLE_INSTRUCTION}\n\n${messageText}`;
|
||||
this.instructionsSent = true;
|
||||
}
|
||||
|
||||
@@ -232,6 +281,13 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
}
|
||||
|
||||
private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void {
|
||||
batch.mode.modelReasoningEffort = effort;
|
||||
this.session.setModelReasoningEffort(effort);
|
||||
this.session.pushKeepAlive();
|
||||
this.options.onReasoningEffortRollback?.(effort);
|
||||
}
|
||||
|
||||
private handleAgentMessage(message: AgentMessage): void {
|
||||
const converted = convertAgentMessage(message);
|
||||
if (converted) {
|
||||
@@ -254,6 +310,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
case 'tool_result':
|
||||
this.messageBuffer.addMessage('Tool result received', 'result');
|
||||
break;
|
||||
case 'usage':
|
||||
break;
|
||||
case 'plan':
|
||||
this.messageBuffer.addMessage('Plan updated', 'status');
|
||||
break;
|
||||
@@ -313,8 +371,9 @@ function toAcpMcpServers(config: Record<string, { command: string; args: string[
|
||||
}
|
||||
|
||||
export async function opencodeRemoteLauncher(
|
||||
session: OpencodeSession
|
||||
session: OpencodeSession,
|
||||
options: OpencodeRemoteLauncherOptions = {}
|
||||
): Promise<'switch' | 'exit'> {
|
||||
const launcher = new OpencodeRemoteLauncher(session);
|
||||
const launcher = new OpencodeRemoteLauncher(session, options);
|
||||
return launcher.launch();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const mockOpencodeSession = vi.hoisted(() => ({
|
||||
setModel: vi.fn(),
|
||||
setPermissionMode: vi.fn(),
|
||||
setModelReasoningEffort: vi.fn(),
|
||||
pushKeepAlive: vi.fn(),
|
||||
thinking: false,
|
||||
stopKeepAlive: vi.fn()
|
||||
@@ -89,6 +90,7 @@ describe('runOpencode set-session-config handler', () => {
|
||||
harness.opencodeLoopError = null;
|
||||
mockOpencodeSession.setModel.mockReset();
|
||||
mockOpencodeSession.setPermissionMode.mockReset();
|
||||
mockOpencodeSession.setModelReasoningEffort.mockReset();
|
||||
mockOpencodeSession.pushKeepAlive.mockReset();
|
||||
harness.session.onUserMessage.mockReset();
|
||||
harness.session.rpcHandlerManager.registerHandler.mockReset();
|
||||
@@ -109,6 +111,20 @@ describe('runOpencode set-session-config handler', () => {
|
||||
return configHandler![1] as (payload: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
it('rejects plan mode for local OpenCode startup', async () => {
|
||||
await expect(runOpencode({ permissionMode: 'plan' })).rejects.toThrow(
|
||||
'OpenCode plan mode is only supported in remote mode'
|
||||
);
|
||||
expect(harness.opencodeLoopArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows plan mode for remote OpenCode startup', async () => {
|
||||
await runOpencode({ permissionMode: 'plan', startingMode: 'remote' });
|
||||
|
||||
expect(harness.opencodeLoopArgs[0]?.permissionMode).toBe('plan');
|
||||
expect(harness.opencodeLoopArgs[0]?.startingMode).toBe('remote');
|
||||
});
|
||||
|
||||
it('applies model change via set-session-config RPC', async () => {
|
||||
await runOpencode({});
|
||||
|
||||
@@ -178,6 +194,30 @@ describe('runOpencode set-session-config handler', () => {
|
||||
expect(applied.permissionMode).toBe('yolo');
|
||||
});
|
||||
|
||||
it('accepts plan mode via set-session-config RPC', async () => {
|
||||
await runOpencode({});
|
||||
|
||||
const handler = getConfigHandler();
|
||||
const result = await handler({ permissionMode: 'plan' }) as Record<string, unknown>;
|
||||
const applied = result.applied as Record<string, unknown>;
|
||||
|
||||
expect(applied.permissionMode).toBe('plan');
|
||||
expect(mockOpencodeSession.setPermissionMode).toHaveBeenLastCalledWith('plan');
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('accepts model reasoning effort via set-session-config RPC', async () => {
|
||||
await runOpencode({});
|
||||
|
||||
const handler = getConfigHandler();
|
||||
const result = await handler({ modelReasoningEffort: 'high' }) as Record<string, unknown>;
|
||||
const applied = result.applied as Record<string, unknown>;
|
||||
|
||||
expect(applied.modelReasoningEffort).toBe('high');
|
||||
expect(mockOpencodeSession.setModelReasoningEffort).toHaveBeenLastCalledWith('high');
|
||||
});
|
||||
|
||||
it('passes initial model from opts through to the loop', async () => {
|
||||
await runOpencode({ model: 'ollama/exaone:4.5-33b-q8' });
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function runOpencode(opts: {
|
||||
startingMode?: 'local' | 'remote';
|
||||
permissionMode?: PermissionMode;
|
||||
model?: string;
|
||||
modelReasoningEffort?: string | null;
|
||||
resumeSessionId?: string;
|
||||
existingSessionId?: string;
|
||||
workingDirectory?: string;
|
||||
@@ -33,6 +34,13 @@ export async function runOpencode(opts: {
|
||||
opts.startingMode = 'remote';
|
||||
}
|
||||
|
||||
const startingMode: 'local' | 'remote' = opts.startingMode
|
||||
?? (startedBy === 'runner' ? 'remote' : 'local');
|
||||
|
||||
if (opts.permissionMode === 'plan' && startingMode !== 'remote') {
|
||||
throw new Error('OpenCode plan mode is only supported in remote mode');
|
||||
}
|
||||
|
||||
const initialState: AgentState = {
|
||||
controlledByUser: false
|
||||
};
|
||||
@@ -41,6 +49,7 @@ export async function runOpencode(opts: {
|
||||
// Mid-session selections are persisted by the hub via the set-session-config RPC,
|
||||
// not by this initial bootstrap.
|
||||
const initialModel = opts.model ?? null;
|
||||
const initialModelReasoningEffort = opts.modelReasoningEffort ?? null;
|
||||
|
||||
const bootstrap = opts.existingSessionId
|
||||
? await bootstrapExistingSession({
|
||||
@@ -54,23 +63,23 @@ export async function runOpencode(opts: {
|
||||
startedBy,
|
||||
workingDirectory,
|
||||
agentState: initialState,
|
||||
model: initialModel ?? undefined
|
||||
model: initialModel ?? undefined,
|
||||
modelReasoningEffort: initialModelReasoningEffort ?? undefined
|
||||
});
|
||||
const { api, session } = bootstrap;
|
||||
|
||||
const startingMode: 'local' | 'remote' = opts.startingMode
|
||||
?? (startedBy === 'runner' ? 'remote' : 'local');
|
||||
|
||||
setControlledByUser(session, startingMode);
|
||||
|
||||
const messageQueue = new MessageQueue2<OpencodeMode>((mode) => hashObject({
|
||||
permissionMode: mode.permissionMode,
|
||||
model: mode.model ?? null
|
||||
model: mode.model ?? null,
|
||||
modelReasoningEffort: mode.modelReasoningEffort ?? null
|
||||
}));
|
||||
|
||||
const sessionWrapperRef: { current: OpencodeSession | null } = { current: null };
|
||||
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
|
||||
let sessionModel: string | null = initialModel;
|
||||
let sessionModelReasoningEffort: string | null = initialModelReasoningEffort;
|
||||
const hookServer = await startOpencodeHookServer({
|
||||
onEvent: (event) => {
|
||||
const currentSession = sessionWrapperRef.current;
|
||||
@@ -102,19 +111,21 @@ export async function runOpencode(opts: {
|
||||
}
|
||||
sessionInstance.setPermissionMode(currentPermissionMode);
|
||||
sessionInstance.setModel(sessionModel);
|
||||
sessionInstance.setModelReasoningEffort(sessionModelReasoningEffort);
|
||||
|
||||
// Notify hub immediately so the UI reflects the change without
|
||||
// waiting for the next 2s keepalive tick.
|
||||
sessionInstance.pushKeepAlive();
|
||||
|
||||
logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}`);
|
||||
logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}, modelReasoningEffort=${sessionModelReasoningEffort ?? '(default)'}`);
|
||||
};
|
||||
|
||||
session.onUserMessage((message, localId) => {
|
||||
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
|
||||
const mode: OpencodeMode = {
|
||||
permissionMode: currentPermissionMode,
|
||||
model: sessionModel ?? undefined
|
||||
model: sessionModel ?? undefined,
|
||||
modelReasoningEffort: sessionModelReasoningEffort
|
||||
};
|
||||
messageQueue.push(formattedText, mode, localId);
|
||||
});
|
||||
@@ -129,6 +140,7 @@ export async function runOpencode(opts: {
|
||||
rpcHandlerManager: session.rpcHandlerManager,
|
||||
flavor: 'opencode',
|
||||
modelMode: 'nullable',
|
||||
modelReasoningEffortMode: 'nullable',
|
||||
onApply: (config) => {
|
||||
if (config.permissionMode !== undefined) {
|
||||
currentPermissionMode = config.permissionMode;
|
||||
@@ -136,6 +148,9 @@ export async function runOpencode(opts: {
|
||||
if (config.model !== undefined) {
|
||||
sessionModel = config.model;
|
||||
}
|
||||
if (config.modelReasoningEffort !== undefined) {
|
||||
sessionModelReasoningEffort = config.modelReasoningEffort;
|
||||
}
|
||||
},
|
||||
onAfterApply: syncSessionMode
|
||||
});
|
||||
@@ -152,10 +167,14 @@ export async function runOpencode(opts: {
|
||||
api,
|
||||
permissionMode: currentPermissionMode,
|
||||
model: sessionModel ?? undefined,
|
||||
modelReasoningEffort: sessionModelReasoningEffort,
|
||||
resumeSessionId: opts.resumeSessionId,
|
||||
hookServer,
|
||||
hookUrl,
|
||||
onModeChange: createModeChangeHandler(session),
|
||||
onReasoningEffortRollback: (effort) => {
|
||||
sessionModelReasoningEffort = effort;
|
||||
},
|
||||
onSessionReady: (instance) => {
|
||||
sessionWrapperRef.current = instance;
|
||||
syncSessionMode();
|
||||
|
||||
@@ -28,6 +28,7 @@ export class OpencodeSession extends AgentSessionBase<OpencodeMode> {
|
||||
startedBy: 'runner' | 'terminal';
|
||||
startingMode: 'local' | 'remote';
|
||||
permissionMode?: PermissionMode;
|
||||
modelReasoningEffort?: string | null;
|
||||
}) {
|
||||
super({
|
||||
api: opts.api,
|
||||
@@ -44,12 +45,14 @@ export class OpencodeSession extends AgentSessionBase<OpencodeMode> {
|
||||
...metadata,
|
||||
opencodeSessionId: sessionId
|
||||
}),
|
||||
permissionMode: opts.permissionMode
|
||||
permissionMode: opts.permissionMode,
|
||||
modelReasoningEffort: opts.modelReasoningEffort
|
||||
});
|
||||
|
||||
this.startedBy = opts.startedBy;
|
||||
this.startingMode = opts.startingMode;
|
||||
this.permissionMode = opts.permissionMode;
|
||||
this.modelReasoningEffort = opts.modelReasoningEffort;
|
||||
}
|
||||
|
||||
addHookEventHandler(cb: (event: OpencodeHookEvent) => void): void {
|
||||
@@ -77,6 +80,10 @@ export class OpencodeSession extends AgentSessionBase<OpencodeMode> {
|
||||
this.model = model;
|
||||
};
|
||||
|
||||
setModelReasoningEffort = (modelReasoningEffort: string | null): void => {
|
||||
this.modelReasoningEffort = modelReasoningEffort;
|
||||
};
|
||||
|
||||
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
|
||||
this.localLaunchFailure = { message, exitReason };
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export type PermissionMode = OpencodePermissionMode;
|
||||
export interface OpencodeMode {
|
||||
permissionMode: PermissionMode;
|
||||
model?: string;
|
||||
modelReasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export type OpencodeHookEvent = {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ApiSessionClient } from '@/api/apiSession';
|
||||
import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types';
|
||||
import { OpencodePermissionHandler } from './permissionHandler';
|
||||
|
||||
vi.mock('@/ui/logger', () => ({
|
||||
logger: {
|
||||
debug: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
type FakeAgentState = {
|
||||
requests: Record<string, unknown>;
|
||||
completedRequests: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createHarness(getPermissionMode: () => 'default' | 'plan' | 'yolo' = () => 'default') {
|
||||
let agentState: FakeAgentState = {
|
||||
requests: {},
|
||||
completedRequests: {}
|
||||
};
|
||||
const rpcHandlers = new Map<string, (params: unknown) => Promise<unknown> | unknown>();
|
||||
let permissionHandler: ((request: PermissionRequest) => void) | null = null;
|
||||
const respondCalls: Array<{
|
||||
sessionId: string;
|
||||
request: PermissionRequest;
|
||||
response: PermissionResponse;
|
||||
}> = [];
|
||||
|
||||
const session = {
|
||||
rpcHandlerManager: {
|
||||
registerHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown) {
|
||||
rpcHandlers.set(method, handler);
|
||||
}
|
||||
},
|
||||
updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) {
|
||||
agentState = handler(agentState);
|
||||
}
|
||||
} as unknown as ApiSessionClient;
|
||||
|
||||
const backend: AgentBackend = {
|
||||
async initialize() {},
|
||||
async newSession() {
|
||||
return 'agent-session';
|
||||
},
|
||||
async prompt() {},
|
||||
async cancelPrompt() {},
|
||||
async respondToPermission(sessionId, request, response) {
|
||||
respondCalls.push({ sessionId, request, response });
|
||||
},
|
||||
onPermissionRequest(handler) {
|
||||
permissionHandler = handler;
|
||||
},
|
||||
async disconnect() {}
|
||||
};
|
||||
|
||||
new OpencodePermissionHandler(session, backend, getPermissionMode);
|
||||
|
||||
return {
|
||||
rpcHandlers,
|
||||
respondCalls,
|
||||
getAgentState: () => agentState,
|
||||
emitPermissionRequest(request: PermissionRequest) {
|
||||
if (!permissionHandler) {
|
||||
throw new Error('Permission handler was not registered');
|
||||
}
|
||||
permissionHandler(request);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function buildRequest(overrides?: Partial<PermissionRequest>): PermissionRequest {
|
||||
return {
|
||||
id: 'perm-1',
|
||||
sessionId: 'session-1',
|
||||
toolCallId: 'perm-1',
|
||||
title: 'Write',
|
||||
rawInput: { path: 'file.ts' },
|
||||
options: [
|
||||
{
|
||||
optionId: 'allow-once',
|
||||
name: 'Allow once',
|
||||
kind: 'allow_once'
|
||||
},
|
||||
{
|
||||
optionId: 'reject-once',
|
||||
name: 'Reject once',
|
||||
kind: 'reject_once'
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('OpencodePermissionHandler plan mode', () => {
|
||||
it('denies non-auto-approved tool requests instead of queueing them', async () => {
|
||||
const harness = createHarness(() => 'plan');
|
||||
|
||||
harness.emitPermissionRequest(buildRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.respondCalls).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
request: expect.objectContaining({ id: 'perm-1', title: 'Write' }),
|
||||
response: { outcome: 'selected', optionId: 'reject-once' }
|
||||
}
|
||||
]);
|
||||
expect(harness.getAgentState().requests).toEqual({});
|
||||
expect(harness.getAgentState().completedRequests).toMatchObject({
|
||||
'perm-1': {
|
||||
tool: 'Write',
|
||||
status: 'denied',
|
||||
decision: 'denied',
|
||||
reason: 'Plan mode blocks tool execution'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels plan-mode requests when OpenCode offers no reject option', async () => {
|
||||
const harness = createHarness(() => 'plan');
|
||||
|
||||
harness.emitPermissionRequest(buildRequest({
|
||||
id: 'perm-no-reject',
|
||||
options: [
|
||||
{
|
||||
optionId: 'allow-once',
|
||||
name: 'Allow once',
|
||||
kind: 'allow_once'
|
||||
}
|
||||
]
|
||||
}));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.respondCalls).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
request: expect.objectContaining({ id: 'perm-no-reject' }),
|
||||
response: { outcome: 'cancelled' }
|
||||
}
|
||||
]);
|
||||
expect(harness.getAgentState().completedRequests).toMatchObject({
|
||||
'perm-no-reject': {
|
||||
status: 'canceled',
|
||||
decision: 'abort'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('still auto-approves hapi title updates in plan mode', async () => {
|
||||
const harness = createHarness(() => 'plan');
|
||||
|
||||
harness.emitPermissionRequest(buildRequest({
|
||||
id: 'perm-title',
|
||||
toolCallId: 'perm-title',
|
||||
title: 'hapi_change_title',
|
||||
rawInput: { title: 'Planning' }
|
||||
}));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.respondCalls).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
request: expect.objectContaining({ id: 'perm-title', title: 'hapi_change_title' }),
|
||||
response: { outcome: 'selected', optionId: 'allow-once' }
|
||||
}
|
||||
]);
|
||||
expect(harness.getAgentState().requests).toEqual({});
|
||||
expect(harness.getAgentState().completedRequests).toMatchObject({
|
||||
'perm-title': {
|
||||
tool: 'hapi_change_title',
|
||||
status: 'approved',
|
||||
decision: 'approved'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,16 +24,28 @@ function deriveToolInput(request: PermissionRequest): unknown {
|
||||
return request.rawOutput;
|
||||
}
|
||||
|
||||
function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null {
|
||||
function pickOptionId(
|
||||
request: PermissionRequest,
|
||||
preferredKinds: string[],
|
||||
options: { fallbackToFirst?: boolean } = {}
|
||||
): string | null {
|
||||
for (const kind of preferredKinds) {
|
||||
const match = request.options.find((option) => option.kind === kind);
|
||||
if (match) {
|
||||
return match.optionId;
|
||||
}
|
||||
}
|
||||
if (options.fallbackToFirst === false) {
|
||||
return null;
|
||||
}
|
||||
return request.options.length > 0 ? request.options[0].optionId : null;
|
||||
}
|
||||
|
||||
function mapPlanModeDenialToOutcome(request: PermissionRequest): PermissionResponse {
|
||||
const optionId = pickOptionId(request, ['reject_once', 'reject_always'], { fallbackToFirst: false });
|
||||
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
|
||||
}
|
||||
|
||||
function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse {
|
||||
if (decision === 'abort') {
|
||||
return { outcome: 'cancelled' };
|
||||
@@ -80,6 +92,11 @@ export class OpencodePermissionHandler extends BasePermissionHandler<PermissionR
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'plan') {
|
||||
void this.denyForPlanMode(request, toolName, toolInput);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingBackendRequests.set(request.id, request);
|
||||
this.addPendingRequest(request.id, toolName, toolInput, {
|
||||
resolve: () => {},
|
||||
@@ -116,6 +133,35 @@ export class OpencodePermissionHandler extends BasePermissionHandler<PermissionR
|
||||
logger.debug(`[Opencode] Auto-approved ${toolName} (${request.id}) mode=${decision}`);
|
||||
}
|
||||
|
||||
private async denyForPlanMode(
|
||||
request: PermissionRequest,
|
||||
toolName: string,
|
||||
toolInput: unknown
|
||||
): Promise<void> {
|
||||
const outcome = mapPlanModeDenialToOutcome(request);
|
||||
await this.backend.respondToPermission(request.sessionId, request, outcome);
|
||||
|
||||
const timestamp = Date.now();
|
||||
const status = outcome.outcome === 'selected' ? 'denied' : 'canceled';
|
||||
this.client.updateAgentState((currentState) => ({
|
||||
...currentState,
|
||||
completedRequests: {
|
||||
...currentState.completedRequests,
|
||||
[request.id]: {
|
||||
tool: toolName,
|
||||
arguments: toolInput,
|
||||
createdAt: timestamp,
|
||||
completedAt: timestamp,
|
||||
status,
|
||||
reason: 'Plan mode blocks tool execution',
|
||||
decision: status === 'denied' ? 'denied' : 'abort'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
logger.debug(`[Opencode] Denied ${toolName} (${request.id}) in plan mode`);
|
||||
}
|
||||
|
||||
protected async handlePermissionResponse(
|
||||
response: PermissionResponseMessage,
|
||||
pending: PendingPermissionRequest<void>
|
||||
|
||||
@@ -19,3 +19,10 @@ export const TITLE_INSTRUCTION = trimIdent(`
|
||||
* The system prompt to inject for OpenCode sessions.
|
||||
*/
|
||||
export const opencodeSystemPrompt = TITLE_INSTRUCTION;
|
||||
|
||||
/**
|
||||
* Instruction prepended to OpenCode prompts while HAPI plan mode is active.
|
||||
*/
|
||||
export const PLAN_MODE_INSTRUCTION = trimIdent(`
|
||||
You are in plan mode. Do not execute tools or make changes. Analyze the request, ask clarifying questions if needed, and respond with a concise implementation plan only.
|
||||
`);
|
||||
|
||||
@@ -60,6 +60,17 @@ describe('buildCliArgs', () => {
|
||||
expect(args).toContain('ollama/exaone:4.5-33b-q8')
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('passes --model-reasoning-effort through for opencode', () => {
|
||||
const args = buildCliArgs('opencode', {
|
||||
directory: '/tmp',
|
||||
modelReasoningEffort: 'high',
|
||||
})
|
||||
expect(args).toContain('--model-reasoning-effort')
|
||||
expect(args).toContain('high')
|
||||
})
|
||||
|
||||
it('validates all known permission modes', () => {
|
||||
for (const mode of ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'ask', 'read-only', 'safe-yolo', 'yolo']) {
|
||||
const args = buildCliArgs('claude', {
|
||||
|
||||
@@ -933,7 +933,7 @@ export function buildCliArgs(
|
||||
if (options.effort && agent === 'claude') {
|
||||
args.push('--effort', options.effort);
|
||||
}
|
||||
if (options.modelReasoningEffort && agent === 'codex') {
|
||||
if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) {
|
||||
args.push('--model-reasoning-effort', options.modelReasoningEffort);
|
||||
}
|
||||
if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) {
|
||||
|
||||
@@ -291,6 +291,34 @@ describe('session model', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects active session config updates when CLI ignores requested keys', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{ of: () => ({ to: () => ({ emit() {} }) }) } as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast() {} } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'session-config-ignored',
|
||||
{ path: '/tmp/project', host: 'localhost', flavor: 'opencode' },
|
||||
null,
|
||||
'default'
|
||||
)
|
||||
engine.handleSessionAlive({ sid: session.id, time: Date.now() })
|
||||
;(engine as any).rpcGateway.requestSessionConfig = async () => ({ applied: {} })
|
||||
|
||||
await expect(
|
||||
engine.applySessionConfig(session.id, { modelReasoningEffort: 'high' })
|
||||
).rejects.toThrow('Session did not apply modelReasoningEffort')
|
||||
expect(engine.getSession(session.id)?.modelReasoningEffort).toBeNull()
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('touches session updatedAt when web sends a message through sync engine', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
|
||||
@@ -459,6 +459,13 @@ export class SyncEngine {
|
||||
throw new Error('Missing applied session config')
|
||||
}
|
||||
|
||||
const requestedKeys = Object.keys(config) as Array<keyof typeof config>
|
||||
for (const key of requestedKeys) {
|
||||
if (!(key in applied)) {
|
||||
throw new Error(`Session did not apply ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionCache.applySessionConfig(sessionId, applied)
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('sessions routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects model reasoning effort changes for non-Codex sessions', async () => {
|
||||
it('rejects model reasoning effort changes for unsupported sessions', async () => {
|
||||
const session = createSession({
|
||||
metadata: {
|
||||
path: '/tmp/project',
|
||||
@@ -185,7 +185,7 @@ describe('sessions routes', () => {
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Model reasoning effort is only supported for Codex sessions'
|
||||
error: 'Model reasoning effort is only supported for Codex and OpenCode sessions'
|
||||
})
|
||||
expect(applySessionConfigCalls).toEqual([])
|
||||
})
|
||||
@@ -208,7 +208,7 @@ describe('sessions routes', () => {
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Model reasoning effort can only be changed for remote Codex sessions'
|
||||
error: 'Model reasoning effort can only be changed for remote sessions'
|
||||
})
|
||||
expect(applySessionConfigCalls).toEqual([])
|
||||
})
|
||||
@@ -229,6 +229,31 @@ describe('sessions routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('applies model reasoning effort changes for remote OpenCode sessions', async () => {
|
||||
const session = createSession({
|
||||
metadata: {
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
flavor: 'opencode'
|
||||
}
|
||||
})
|
||||
const { app, applySessionConfigCalls } = createApp(session)
|
||||
|
||||
const response = await app.request('/api/sessions/session-1/model-reasoning-effort', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ modelReasoningEffort: 'high' })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ ok: true })
|
||||
expect(applySessionConfigCalls).toEqual([
|
||||
['session-1', { modelReasoningEffort: 'high' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('applies model changes for remote Codex sessions', async () => {
|
||||
const { app, applySessionConfigCalls } = createApp(createSession())
|
||||
|
||||
@@ -442,6 +467,49 @@ describe('sessions routes', () => {
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects OpenCode plan mode changes for local sessions', async () => {
|
||||
const session = createSession({
|
||||
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' },
|
||||
agentState: {
|
||||
controlledByUser: true,
|
||||
requests: {},
|
||||
completedRequests: {}
|
||||
}
|
||||
})
|
||||
const { app, applySessionConfigCalls } = createApp(session)
|
||||
|
||||
const response = await app.request('/api/sessions/session-1/permission-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'plan' })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'OpenCode plan mode is only supported for remote sessions'
|
||||
})
|
||||
expect(applySessionConfigCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('applies OpenCode plan mode changes for remote sessions', async () => {
|
||||
const session = createSession({
|
||||
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }
|
||||
})
|
||||
const { app, applySessionConfigCalls } = createApp(session)
|
||||
|
||||
const response = await app.request('/api/sessions/session-1/permission-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'plan' })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ ok: true })
|
||||
expect(applySessionConfigCalls).toEqual([
|
||||
['session-1', { permissionMode: 'plan' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('applies permission mode changes for inactive sessions', async () => {
|
||||
const session = createSession({
|
||||
active: false,
|
||||
|
||||
@@ -279,6 +279,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
if (!isPermissionModeAllowedForFlavor(mode, flavor)) {
|
||||
return c.json({ error: 'Invalid permission mode for session flavor' }, 400)
|
||||
}
|
||||
if (flavor === 'opencode' && mode === 'plan' && sessionResult.session.agentState?.controlledByUser === true) {
|
||||
return c.json({ error: 'OpenCode plan mode is only supported for remote sessions' }, 409)
|
||||
}
|
||||
|
||||
try {
|
||||
await engine.applySessionConfig(sessionResult.sessionId, { permissionMode: mode })
|
||||
@@ -369,11 +372,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
}
|
||||
|
||||
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
|
||||
if (flavor !== 'codex') {
|
||||
return c.json({ error: 'Model reasoning effort is only supported for Codex sessions' }, 400)
|
||||
if (flavor !== 'codex' && flavor !== 'opencode') {
|
||||
return c.json({ error: 'Model reasoning effort is only supported for Codex and OpenCode sessions' }, 400)
|
||||
}
|
||||
if (sessionResult.session.agentState?.controlledByUser === true) {
|
||||
return c.json({ error: 'Model reasoning effort can only be changed for remote Codex sessions' }, 409)
|
||||
return c.json({ error: 'Model reasoning effort can only be changed for remote sessions' }, 409)
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null)
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number]
|
||||
export const KIMI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const
|
||||
export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number]
|
||||
|
||||
export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const
|
||||
export const OPENCODE_PERMISSION_MODES = ['default', 'plan', 'yolo'] as const
|
||||
export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number]
|
||||
|
||||
export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'yolo'] as const
|
||||
|
||||
@@ -641,6 +641,42 @@ describe('normalizeDecryptedMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes token_count payloads with explicit contextTokens', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'codex',
|
||||
data: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: 8_119,
|
||||
outputTokens: 2,
|
||||
cachedInputTokens: 5_760,
|
||||
thoughtTokens: 11,
|
||||
totalTokens: 13_892
|
||||
},
|
||||
contextTokens: 13_879,
|
||||
modelContextWindow: 65_536
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
role: 'event',
|
||||
usage: {
|
||||
input_tokens: 8119,
|
||||
output_tokens: 2,
|
||||
cache_read_input_tokens: 5760,
|
||||
context_tokens: 13879,
|
||||
context_window: 65536
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes Codex context_compacted as a compact event', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
|
||||
@@ -88,7 +88,12 @@ function normalizeCodexTokenUsage(value: unknown, data?: Record<string, unknown>
|
||||
?? usageSource.cacheReadInputTokens
|
||||
?? usageSource.cache_read_input_tokens
|
||||
) ?? undefined,
|
||||
context_tokens: inputTokens,
|
||||
context_tokens: asNumber(
|
||||
info.contextTokens
|
||||
?? info.context_tokens
|
||||
?? usageSource.contextTokens
|
||||
?? usageSource.context_tokens
|
||||
) ?? inputTokens,
|
||||
context_window: asNumber(info.modelContextWindow ?? info.model_context_window) ?? undefined,
|
||||
thread_id: asString(
|
||||
data?.thread_id
|
||||
|
||||
@@ -298,7 +298,9 @@ export function HappyComposer(props: {
|
||||
[agentFlavor, model, availableModelOptions]
|
||||
)
|
||||
const codexReasoningEffortOptions = useMemo(
|
||||
() => agentFlavor === 'codex' ? getCodexComposerReasoningEffortOptions(modelReasoningEffort) : [],
|
||||
() => agentFlavor === 'codex' || agentFlavor === 'opencode'
|
||||
? getCodexComposerReasoningEffortOptions(modelReasoningEffort, agentFlavor)
|
||||
: [],
|
||||
[agentFlavor, modelReasoningEffort]
|
||||
)
|
||||
const claudeEffortOptions = useMemo(
|
||||
|
||||
@@ -205,7 +205,7 @@ export function StatusBar(props: {
|
||||
const collaborationModeLabel = displayCollaborationMode
|
||||
? getCodexCollaborationModeLabel(displayCollaborationMode)
|
||||
: null
|
||||
const codexReasoningLabel = props.agentFlavor === 'codex'
|
||||
const codexReasoningLabel = (props.agentFlavor === 'codex' || props.agentFlavor === 'opencode')
|
||||
? formatCodexReasoningLabel(props.modelReasoningEffort)
|
||||
: null
|
||||
const codexFastMode = props.agentFlavor === 'codex'
|
||||
|
||||
@@ -4,11 +4,13 @@ export type CodexComposerReasoningEffortOption = {
|
||||
}
|
||||
|
||||
const CODEX_REASONING_EFFORT_PRESETS = ['low', 'medium', 'high', 'xhigh'] as const
|
||||
const CODEX_REASONING_EFFORT_LABELS: Record<(typeof CODEX_REASONING_EFFORT_PRESETS)[number], string> = {
|
||||
const OPENCODE_REASONING_EFFORT_PRESETS = ['low', 'medium', 'high', 'max'] as const
|
||||
const CODEX_REASONING_EFFORT_LABELS: Record<string, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
xhigh: 'XHigh'
|
||||
xhigh: 'XHigh',
|
||||
max: 'Max'
|
||||
}
|
||||
|
||||
function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null {
|
||||
@@ -25,15 +27,19 @@ function formatCodexReasoningEffortLabel(effort: string): string {
|
||||
?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`
|
||||
}
|
||||
|
||||
export function getCodexComposerReasoningEffortOptions(currentEffort?: string | null): CodexComposerReasoningEffortOption[] {
|
||||
export function getCodexComposerReasoningEffortOptions(
|
||||
currentEffort?: string | null,
|
||||
flavor?: string | null
|
||||
): CodexComposerReasoningEffortOption[] {
|
||||
const normalizedCurrentEffort = normalizeCodexComposerReasoningEffort(currentEffort)
|
||||
const presets = flavor === 'opencode' ? OPENCODE_REASONING_EFFORT_PRESETS : CODEX_REASONING_EFFORT_PRESETS
|
||||
const options: CodexComposerReasoningEffortOption[] = [
|
||||
{ value: null, label: 'Default' }
|
||||
]
|
||||
|
||||
if (
|
||||
normalizedCurrentEffort
|
||||
&& !CODEX_REASONING_EFFORT_PRESETS.includes(normalizedCurrentEffort as typeof CODEX_REASONING_EFFORT_PRESETS[number])
|
||||
&& !(presets as readonly string[]).includes(normalizedCurrentEffort)
|
||||
) {
|
||||
options.push({
|
||||
value: normalizedCurrentEffort,
|
||||
@@ -41,7 +47,7 @@ export function getCodexComposerReasoningEffortOptions(currentEffort?: string |
|
||||
})
|
||||
}
|
||||
|
||||
options.push(...CODEX_REASONING_EFFORT_PRESETS.map((effort) => ({
|
||||
options.push(...presets.map((effort) => ({
|
||||
value: effort,
|
||||
label: CODEX_REASONING_EFFORT_LABELS[effort]
|
||||
})))
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ReasoningEffortSelector(props: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (props.agent !== 'codex') {
|
||||
if (props.agent !== 'codex' && props.agent !== 'opencode') {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ReasoningEffortSelector(props: {
|
||||
disabled={props.isDisabled}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
|
||||
>
|
||||
{CODEX_REASONING_EFFORT_OPTIONS.map((option) => (
|
||||
{CODEX_REASONING_EFFORT_OPTIONS.filter((option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max').map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function NewSession(props: {
|
||||
useEffect(() => {
|
||||
setModel('auto')
|
||||
setEffort('auto')
|
||||
setModelReasoningEffort('default')
|
||||
}, [agent])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -341,7 +342,7 @@ export function NewSession(props: {
|
||||
? (opencodeSelectedModel ?? undefined)
|
||||
: (model !== 'auto' ? model : undefined)
|
||||
const resolvedEffort = agent === 'claude' && effort !== 'auto' ? effort : undefined
|
||||
const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default'
|
||||
const resolvedModelReasoningEffort = (agent === 'codex' || agent === 'opencode') && modelReasoningEffort !== 'default'
|
||||
? modelReasoningEffort
|
||||
: undefined
|
||||
const result = await spawnSession({
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AgentFlavor } from '@hapi/protocol'
|
||||
|
||||
export type AgentType = AgentFlavor
|
||||
export type SessionType = 'simple' | 'worktree'
|
||||
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
||||
export type ClaudeEffort = 'auto' | 'medium' | 'high' | 'max'
|
||||
|
||||
function modelPresetOptions<TModel extends string>(
|
||||
@@ -43,6 +43,7 @@ export const CODEX_REASONING_EFFORT_OPTIONS: { value: CodexReasoningEffort; labe
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'XHigh' },
|
||||
{ value: 'max', label: 'Max' },
|
||||
]
|
||||
|
||||
export const CLAUDE_EFFORT_OPTIONS: { value: ClaudeEffort; label: string }[] = [
|
||||
|
||||
@@ -623,7 +623,7 @@ export function SessionChat(props: {
|
||||
collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined}
|
||||
threadGoal={reduced.latestGoal}
|
||||
model={props.session.model}
|
||||
modelReasoningEffort={agentFlavor === 'codex' ? props.session.modelReasoningEffort : undefined}
|
||||
modelReasoningEffort={agentFlavor === 'codex' || agentFlavor === 'opencode' ? props.session.modelReasoningEffort : undefined}
|
||||
effort={props.session.effort}
|
||||
agentFlavor={agentFlavor}
|
||||
availableModelOptions={
|
||||
@@ -658,7 +658,7 @@ export function SessionChat(props: {
|
||||
: handleModelChange
|
||||
}
|
||||
onModelReasoningEffortChange={
|
||||
agentFlavor === 'codex' && props.session.active && !controlledByUser
|
||||
(agentFlavor === 'codex' || agentFlavor === 'opencode') && props.session.active && !controlledByUser
|
||||
? handleModelReasoningEffortChange
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -106,11 +106,11 @@ export function useSessionActions(
|
||||
if (!api || !sessionId) {
|
||||
throw new Error('Session unavailable')
|
||||
}
|
||||
if (agentFlavor !== 'codex') {
|
||||
throw new Error('Model reasoning effort is only supported for Codex sessions')
|
||||
if (agentFlavor !== 'codex' && agentFlavor !== 'opencode') {
|
||||
throw new Error('Model reasoning effort is only supported for Codex and OpenCode sessions')
|
||||
}
|
||||
if (!codexCollaborationModeSupported) {
|
||||
throw new Error('Model reasoning effort is only supported for remote Codex sessions')
|
||||
if (agentFlavor === 'codex' && !codexCollaborationModeSupported) {
|
||||
throw new Error('Model reasoning effort is only supported for remote sessions')
|
||||
}
|
||||
await api.setModelReasoningEffort(sessionId, modelReasoningEffort)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getOpencodeModelsRefetchInterval, shouldRetryOpencodeModelsQuery } from './useOpencodeModels'
|
||||
|
||||
describe('useOpencodeModels retry policy', () => {
|
||||
it('retries early failures while the session model RPC may still be registering', () => {
|
||||
expect(shouldRetryOpencodeModelsQuery(0)).toBe(true)
|
||||
expect(shouldRetryOpencodeModelsQuery(2)).toBe(true)
|
||||
expect(shouldRetryOpencodeModelsQuery(3)).toBe(false)
|
||||
})
|
||||
|
||||
it('polls briefly until OpenCode session models are discovered', () => {
|
||||
expect(getOpencodeModelsRefetchInterval(true, undefined, 0)).toBe(1000)
|
||||
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 1)).toBe(1000)
|
||||
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 2)).toBe(1000)
|
||||
})
|
||||
|
||||
it('stops polling once model options are available or the query is disabled', () => {
|
||||
expect(getOpencodeModelsRefetchInterval(true, {
|
||||
success: true,
|
||||
availableModels: [{ modelId: 'provider/model', name: 'Provider Model' }],
|
||||
currentModelId: 'provider/model'
|
||||
}, 1)).toBe(false)
|
||||
expect(getOpencodeModelsRefetchInterval(false, undefined, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('stops polling after the discovery poll cap', () => {
|
||||
expect(getOpencodeModelsRefetchInterval(true, undefined, 10)).toBe(false)
|
||||
expect(getOpencodeModelsRefetchInterval(true, { success: true, availableModels: [] }, 10)).toBe(false)
|
||||
expect(getOpencodeModelsRefetchInterval(true, { success: false, error: 'not ready' }, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { OpencodeModelsResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { OpencodeModelSummary } from '@/types/api'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
|
||||
export function shouldRetryOpencodeModelsQuery(failureCount: number): boolean {
|
||||
return failureCount < 3
|
||||
}
|
||||
|
||||
const MAX_OPENCODE_MODEL_DISCOVERY_POLLS = 10
|
||||
|
||||
export function getOpencodeModelsRefetchInterval(
|
||||
enabled: boolean,
|
||||
data: OpencodeModelsResponse | undefined,
|
||||
pollCount: number
|
||||
): 1000 | false {
|
||||
if (!enabled || pollCount >= MAX_OPENCODE_MODEL_DISCOVERY_POLLS) {
|
||||
return false
|
||||
}
|
||||
if (!data) {
|
||||
return 1000
|
||||
}
|
||||
if (data.success === false) {
|
||||
return 1000
|
||||
}
|
||||
return (data.availableModels?.length ?? 0) > 0 ? false : 1000
|
||||
}
|
||||
|
||||
export function useOpencodeModels(args: {
|
||||
api: ApiClient | null
|
||||
sessionId?: string | null
|
||||
@@ -31,7 +55,12 @@ export function useOpencodeModels(args: {
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
retry: (failureCount) => shouldRetryOpencodeModelsQuery(failureCount),
|
||||
refetchInterval: (query) => getOpencodeModelsRefetchInterval(
|
||||
enabled,
|
||||
query.state.data as OpencodeModelsResponse | undefined,
|
||||
query.state.dataUpdateCount + query.state.errorUpdateCount
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user