mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(opencode): use ACP-reported reasoning effort options (#853)
* fix(opencode): use ACP-reported reasoning effort options Expose thought_level options from OpenCode ACP to the web UI via RPC/API instead of hardcoded presets, and validate effort values before setConfigOption. Fixes #852 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): sync hub effort after coerced setConfigOption When resolveThoughtLevelEffort falls back to a different supported value, roll back session state after a successful ACP update so keepalive and the web UI do not keep advertising the rejected effort. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -255,7 +255,48 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
expect(harness.promptCount).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects unsupported reasoning effort values before calling setConfigOption', async () => {
|
||||
harness.thoughtLevelOption = {
|
||||
id: 'effort',
|
||||
currentValue: 'low',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'medium', name: 'Medium' }
|
||||
]
|
||||
};
|
||||
const { session, setModelReasoningEffort } = createSessionStub([
|
||||
{ message: 'first', mode: createModeWithEffort(undefined, 'high') }
|
||||
]);
|
||||
|
||||
await opencodeRemoteLauncher(session as never);
|
||||
|
||||
expect(harness.setConfigOptionArgs).toEqual([]);
|
||||
expect(setModelReasoningEffort).toHaveBeenCalledWith('low');
|
||||
expect(harness.promptCount).toBe(1);
|
||||
});
|
||||
|
||||
it('syncs hub effort state after coercing an unsupported request to a different supported value', async () => {
|
||||
harness.thoughtLevelOption = {
|
||||
id: 'effort',
|
||||
currentValue: 'high',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'medium', name: 'Medium' }
|
||||
]
|
||||
};
|
||||
const { session, setModelReasoningEffort, pushKeepAlive } = createSessionStub([
|
||||
{ message: 'first', mode: createModeWithEffort(undefined, 'max') }
|
||||
]);
|
||||
|
||||
await opencodeRemoteLauncher(session as never);
|
||||
|
||||
expect(harness.setConfigOptionArgs).toEqual([
|
||||
{ sessionId: 'acp-session-1', configId: 'effort', value: 'low' }
|
||||
]);
|
||||
expect(setModelReasoningEffort).toHaveBeenCalledWith('low');
|
||||
expect(pushKeepAlive).toHaveBeenCalledTimes(1);
|
||||
expect(harness.promptCount).toBe(1);
|
||||
});
|
||||
|
||||
it('resets to the backend launch-time default model when the queued mode.model is null', async () => {
|
||||
// Seed the backend with a launch-time default model so the launcher
|
||||
@@ -414,6 +455,48 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('registers a listOpencodeReasoningEffortOptions RPC handler that returns ACP options', async () => {
|
||||
harness.thoughtLevelOption = {
|
||||
id: 'effort',
|
||||
currentValue: 'low',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'medium', name: 'Medium' }
|
||||
]
|
||||
};
|
||||
const { session, rpcHandlers } = createSessionStub([
|
||||
{ message: 'first', mode: createMode() }
|
||||
]);
|
||||
await opencodeRemoteLauncher(session as never);
|
||||
|
||||
const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions');
|
||||
expect(handler).toBeDefined();
|
||||
const result = await handler!(undefined) as Record<string, unknown>;
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'medium', name: 'Medium' }
|
||||
],
|
||||
currentValue: 'low'
|
||||
});
|
||||
});
|
||||
|
||||
it('listOpencodeReasoningEffortOptions handler returns unavailable when backend has no thought level option', async () => {
|
||||
const { session, rpcHandlers } = createSessionStub([
|
||||
{ message: 'first', mode: createMode() }
|
||||
]);
|
||||
await opencodeRemoteLauncher(session as never);
|
||||
|
||||
const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions');
|
||||
expect(handler).toBeDefined();
|
||||
const result = await handler!(undefined) as Record<string, unknown>;
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'OpenCode reasoning effort options are not available'
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes setModel after the previous prompt resolves', async () => {
|
||||
const { session } = createSessionStub([
|
||||
{ message: 'first', mode: createMode('ollama/a') },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
|
||||
import { createOpencodeBackend } from './utils/opencodeBackend';
|
||||
import { OpencodePermissionHandler } from './utils/permissionHandler';
|
||||
import { PLAN_MODE_INSTRUCTION, TITLE_INSTRUCTION } from './utils/systemPrompt';
|
||||
import { resolveThoughtLevelEffort } from './thoughtLevelEffort';
|
||||
|
||||
type OpencodeRemoteLauncherOptions = {
|
||||
onReasoningEffortRollback?: (effort: string | null) => void;
|
||||
@@ -123,6 +124,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
};
|
||||
});
|
||||
|
||||
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeReasoningEffortOptions, async () => {
|
||||
const effortOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
|
||||
if (!effortOption) {
|
||||
return { success: false, error: 'OpenCode reasoning effort options are not available' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
options: effortOption.options,
|
||||
currentValue: effortOption.currentValue ?? null
|
||||
};
|
||||
});
|
||||
|
||||
this.permissionHandler = new OpencodePermissionHandler(
|
||||
session.client,
|
||||
backend,
|
||||
@@ -206,29 +219,46 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
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)'}.`
|
||||
});
|
||||
const resolvedEffort = resolveThoughtLevelEffort(
|
||||
requestedEffort,
|
||||
thoughtLevelOption,
|
||||
this.currentBackendEffort ?? this.defaultBackendEffort
|
||||
);
|
||||
if (!resolvedEffort || resolvedEffort === this.currentBackendEffort) {
|
||||
if (requestedEffort !== resolvedEffort) {
|
||||
logger.warn(
|
||||
`[opencode-remote] Unsupported reasoning effort "${requestedEffort}"; continuing with ${resolvedEffort ?? this.currentBackendEffort ?? '(default)'}`
|
||||
);
|
||||
this.rollbackReasoningEffort(batch, resolvedEffort ?? this.currentBackendEffort);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${resolvedEffort}`);
|
||||
try {
|
||||
await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, resolvedEffort);
|
||||
this.currentBackendEffort = resolvedEffort;
|
||||
this.setEffortSupported = true;
|
||||
if (requestedEffort !== resolvedEffort) {
|
||||
this.rollbackReasoningEffort(batch, resolvedEffort);
|
||||
}
|
||||
} 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 ${resolvedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.`
|
||||
});
|
||||
}
|
||||
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
|
||||
}
|
||||
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveThoughtLevelEffort } from './thoughtLevelEffort';
|
||||
|
||||
const thoughtLevelOption = {
|
||||
id: 'effort',
|
||||
category: 'thought_level',
|
||||
currentValue: 'low',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'medium', name: 'Medium' }
|
||||
]
|
||||
};
|
||||
|
||||
describe('resolveThoughtLevelEffort', () => {
|
||||
it('returns the requested value when it is supported', () => {
|
||||
expect(resolveThoughtLevelEffort('medium', thoughtLevelOption, 'low')).toBe('medium');
|
||||
});
|
||||
|
||||
it('falls back to the current backend effort when the request is unsupported', () => {
|
||||
expect(resolveThoughtLevelEffort('high', thoughtLevelOption, 'low')).toBe('low');
|
||||
});
|
||||
|
||||
it('falls back to the ACP current value when the backend effort is also unsupported', () => {
|
||||
expect(resolveThoughtLevelEffort('high', thoughtLevelOption, 'max')).toBe('low');
|
||||
});
|
||||
|
||||
it('falls back to the first supported option when nothing else matches', () => {
|
||||
const option = {
|
||||
...thoughtLevelOption,
|
||||
currentValue: 'high'
|
||||
};
|
||||
expect(resolveThoughtLevelEffort('max', option, null)).toBe('low');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AgentSessionConfigOptionDescriptor } from '@/agent/types';
|
||||
|
||||
export function resolveThoughtLevelEffort(
|
||||
requested: string,
|
||||
thoughtLevelOption: AgentSessionConfigOptionDescriptor,
|
||||
fallback: string | null
|
||||
): string | null {
|
||||
const supported = new Set(thoughtLevelOption.options.map((option) => option.value));
|
||||
if (supported.has(requested)) {
|
||||
return requested;
|
||||
}
|
||||
if (fallback && supported.has(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
const current = thoughtLevelOption.currentValue;
|
||||
if (current && supported.has(current)) {
|
||||
return current;
|
||||
}
|
||||
return thoughtLevelOption.options[0]?.value ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user