mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user