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