mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix: restore opencode hook plugin channel and coalesce ACP reasoning chunks across all consumers (#631)
This commit is contained in:
@@ -658,7 +658,7 @@ describe('AcpMessageHandler', () => {
|
||||
expect((messages[0] as { text: string }).text).toMatch(/^Claude AI usage limit warning\|/);
|
||||
});
|
||||
|
||||
it('forwards agent_thought_chunk as a reasoning message', () => {
|
||||
it('forwards agent_thought_chunk as a reasoning message after flush', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
@@ -667,6 +667,9 @@ describe('AcpMessageHandler', () => {
|
||||
content: { type: 'text', text: 'thinking about the problem' }
|
||||
});
|
||||
|
||||
// Chunks are buffered, not emitted inline.
|
||||
expect(messages).toHaveLength(0);
|
||||
handler.flushReasoning();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual({ type: 'reasoning', text: 'thinking about the problem' });
|
||||
});
|
||||
@@ -697,16 +700,16 @@ describe('AcpMessageHandler', () => {
|
||||
content: { type: 'text', text: 'mid-stream thought' }
|
||||
});
|
||||
|
||||
// The thought chunk must not flush the live text buffer — otherwise
|
||||
// a single text segment would split across two messages.
|
||||
handler.flushReasoning();
|
||||
handler.flushText();
|
||||
|
||||
// Both messages are delivered intact with no loss. Reasoning is
|
||||
// emitted inline (see AcpMessageHandler) so it precedes the
|
||||
// flushed text segment — this is an intentional contract to let
|
||||
// thoughts and text interleave without splitting a live segment.
|
||||
expect(messages).toHaveLength(2);
|
||||
// Reasoning was buffered separately and is now delivered as a single
|
||||
// coalesced message. The text buffer survived the thought.
|
||||
expect(messages).toContainEqual({ type: 'reasoning', text: 'mid-stream thought' });
|
||||
expect(messages).toContainEqual({ type: 'text', text: 'partial answer' });
|
||||
expect(messages[0]).toEqual({ type: 'reasoning', text: 'mid-stream thought' });
|
||||
});
|
||||
|
||||
it('does not drop thought chunks annotated with a non-assistant audience', () => {
|
||||
@@ -721,32 +724,155 @@ describe('AcpMessageHandler', () => {
|
||||
annotations: { audience: ['user'] }
|
||||
}
|
||||
});
|
||||
handler.flushReasoning();
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual({ type: 'reasoning', text: 'private reasoning' });
|
||||
});
|
||||
|
||||
it('forwards sequential thought chunks in arrival order as separate reasoning messages', () => {
|
||||
it('coalesces sequential thought chunks into a single reasoning message', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: 'first thought' }
|
||||
content: { type: 'text', text: 'first thought ' }
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: 'second thought' }
|
||||
content: { type: 'text', text: 'second thought ' }
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: 'third thought' }
|
||||
});
|
||||
handler.flushReasoning();
|
||||
|
||||
// OpenCode/Zen streams thoughts at one chunk per token; emitting
|
||||
// each chunk as its own reasoning message made the web reducer
|
||||
// render one row per token. The handler now coalesces a thought
|
||||
// segment into a single reasoning message.
|
||||
expect(messages).toEqual([
|
||||
{ type: 'reasoning', text: 'first thought second thought third thought' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits buffered reasoning before a tool_call boundary', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: 'I should call the tool. ' }
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: 'Calling now.' }
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall,
|
||||
toolCallId: 'tc-1',
|
||||
title: 'do_thing',
|
||||
kind: 'execute',
|
||||
rawInput: { foo: 1 },
|
||||
status: 'in_progress'
|
||||
});
|
||||
|
||||
// Reasoning is coalesced and emitted before the tool call so the
|
||||
// arrival order between thought and tool lifecycle is preserved.
|
||||
expect(messages[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
text: 'I should call the tool. Calling now.'
|
||||
});
|
||||
expect(messages[1]).toMatchObject({ type: 'tool_call', id: 'tc-1' });
|
||||
});
|
||||
|
||||
// Locks the flush-before-every-non-thought-boundary contract introduced
|
||||
// in this fix: a future refactor that forgets to call flushReasoning() in
|
||||
// one branch of handleUpdate would otherwise silently regress reasoning
|
||||
// ordering for that update type.
|
||||
it.each([
|
||||
[
|
||||
'agentMessageChunk',
|
||||
{
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: 'visible answer' }
|
||||
}
|
||||
],
|
||||
[
|
||||
'toolCall',
|
||||
{
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall,
|
||||
toolCallId: 'tc-x',
|
||||
title: 'do_thing',
|
||||
kind: 'execute',
|
||||
rawInput: {},
|
||||
status: 'in_progress'
|
||||
}
|
||||
],
|
||||
[
|
||||
'plan',
|
||||
{
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.plan,
|
||||
entries: [{ content: 'Step 1', priority: 'high', status: 'pending' }]
|
||||
}
|
||||
]
|
||||
])('flushes buffered reasoning before %s', (_label, boundaryUpdate) => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: 'thinking first' }
|
||||
});
|
||||
handler.handleUpdate(boundaryUpdate);
|
||||
handler.drainBuffers();
|
||||
|
||||
// Reasoning must arrive at index 0, before anything the boundary
|
||||
// update produced.
|
||||
expect(messages[0]).toEqual({ type: 'reasoning', text: 'thinking first' });
|
||||
expect(messages.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('drops whitespace-only buffered reasoning rather than emitting an empty bubble', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: ' ' }
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: '\n\n' }
|
||||
});
|
||||
handler.drainBuffers();
|
||||
|
||||
// A whitespace-only reasoning bubble in the web UI is visible only as
|
||||
// empty space — drop it instead.
|
||||
expect(messages.filter((m) => m.type === 'reasoning')).toEqual([]);
|
||||
});
|
||||
|
||||
it('drainBuffers emits reasoning before any pending text', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
// Build up text and reasoning together — text first, then thought
|
||||
// interleaved (per the existing intra-segment contract).
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: 'visible' }
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk,
|
||||
content: { type: 'text', text: 'silent' }
|
||||
});
|
||||
|
||||
handler.drainBuffers();
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ type: 'reasoning', text: 'first thought' },
|
||||
{ type: 'reasoning', text: 'second thought' },
|
||||
{ type: 'reasoning', text: 'third thought' }
|
||||
{ type: 'reasoning', text: 'silent' },
|
||||
{ type: 'text', text: 'visible' }
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -273,6 +273,11 @@ function getSuffixPrefixOverlap(base: string, next: string): number {
|
||||
export class AcpMessageHandler {
|
||||
private readonly toolCalls = new Map<string, { name: string; input: unknown }>();
|
||||
private bufferedText = '';
|
||||
// Array buffer avoids the O(N²) string concatenation that per-token
|
||||
// ACP streams (OpenCode/Zen emits one chunk per generated token) would
|
||||
// otherwise incur — a 10k-token reasoning trace allocates 10k full-buffer
|
||||
// copies if we use `+=`.
|
||||
private bufferedReasoning: string[] = [];
|
||||
|
||||
constructor(private readonly onMessage: (message: AgentMessage) => void) {}
|
||||
|
||||
@@ -291,6 +296,50 @@ export class AcpMessageHandler {
|
||||
this.onMessage({ type: 'text', text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits buffered thought chunks as a single reasoning message and clears
|
||||
* the buffer. ACP agents (notably OpenCode/Zen) stream thoughts at the
|
||||
* granularity of one chunk per token; emitting each chunk inline would
|
||||
* make the web reducer render one row per token. Coalescing here keeps
|
||||
* the reasoning block intact while preserving its position relative to
|
||||
* adjacent text segments and tool events.
|
||||
*
|
||||
* Called automatically before every non-thought update inside
|
||||
* `handleUpdate`, and externally at turn boundaries by `drainBuffers`
|
||||
* from AcpSdkBackend.
|
||||
*
|
||||
* Whitespace-only buffers are dropped: a turn that happens to emit a
|
||||
* single whitespace token would otherwise render an empty Reasoning row
|
||||
* in the web UI.
|
||||
*/
|
||||
flushReasoning(): void {
|
||||
if (this.bufferedReasoning.length === 0) {
|
||||
return;
|
||||
}
|
||||
const text = this.bufferedReasoning.join('');
|
||||
this.bufferedReasoning = [];
|
||||
if (text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
this.onMessage({ type: 'reasoning', text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point for turn-boundary draining. Reasoning is always
|
||||
* flushed before text so that, even when the agent streamed thoughts
|
||||
* after a text segment had already opened, the final rendered turn
|
||||
* shows the Reasoning block above the answer (matching the web UI
|
||||
* component layout). This is a deliberate UX-driven ordering — not
|
||||
* a preservation of the agent's arrival order, which could place
|
||||
* text before reasoning within a single turn. Callers in
|
||||
* `AcpSdkBackend` must use this rather than the individual flush
|
||||
* methods to keep the order invariant enforced in one place.
|
||||
*/
|
||||
drainBuffers(): void {
|
||||
this.flushReasoning();
|
||||
this.flushText();
|
||||
}
|
||||
|
||||
private appendTextChunk(text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
@@ -331,6 +380,31 @@ export class AcpMessageHandler {
|
||||
const updateType = asString(update.sessionUpdate);
|
||||
if (!updateType) return;
|
||||
|
||||
if (updateType === ACP_SESSION_UPDATE_TYPES.agentThoughtChunk) {
|
||||
// Thought chunks do not participate in intra-turn ordering and
|
||||
// must not flush the text buffer (that would split a live text
|
||||
// segment). Coalesce them into a single reasoning buffer so the
|
||||
// web UI renders one Reasoning block per turn segment instead
|
||||
// of one row per streaming token.
|
||||
//
|
||||
// We deliberately do not reuse `extractTextContent` here: that
|
||||
// helper applies an assistant-audience filter which only makes
|
||||
// sense for regular message chunks. Thought content has no
|
||||
// meaningful audience — a non-assistant audience annotation
|
||||
// should not cause the reasoning to be silently dropped.
|
||||
const content = update.content;
|
||||
if (isObject(content) && content.type === 'text' && typeof content.text === 'string' && content.text.length > 0) {
|
||||
this.bufferedReasoning.push(content.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Any non-thought update is a reasoning-segment boundary: emit the
|
||||
// accumulated thought now so it arrives before the next event in
|
||||
// the same arrival order that streamed in. Tool calls / plans
|
||||
// additionally flush the text buffer below.
|
||||
this.flushReasoning();
|
||||
|
||||
if (updateType === ACP_SESSION_UPDATE_TYPES.agentMessageChunk) {
|
||||
const content = update.content;
|
||||
const text = extractTextContent(content);
|
||||
@@ -366,28 +440,6 @@ export class AcpMessageHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateType === ACP_SESSION_UPDATE_TYPES.agentThoughtChunk) {
|
||||
// Thought chunks do not participate in intra-turn ordering and
|
||||
// must not flush the text buffer (that would split a live text
|
||||
// segment). Forward as a reasoning message so the web UI can
|
||||
// render the model's thinking in a collapsible block.
|
||||
//
|
||||
// Reasoning messages are emitted inline (never buffered), so they
|
||||
// arrive before any still-pending text segment is flushed. Tests
|
||||
// in this file rely on that contract.
|
||||
//
|
||||
// We deliberately do not reuse `extractTextContent` here: that
|
||||
// helper applies an assistant-audience filter which only makes
|
||||
// sense for regular message chunks. Thought content has no
|
||||
// meaningful audience — a non-assistant audience annotation
|
||||
// should not cause the reasoning to be silently dropped.
|
||||
const content = update.content;
|
||||
if (isObject(content) && content.type === 'text' && typeof content.text === 'string' && content.text.length > 0) {
|
||||
this.onMessage({ type: 'reasoning', text: content.text });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateType === ACP_SESSION_UPDATE_TYPES.toolCall) {
|
||||
// A new tool invocation closes the preceding text segment.
|
||||
// Flushing here preserves the arrival order between text and
|
||||
|
||||
@@ -210,7 +210,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
AcpSdkBackend.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS,
|
||||
AcpSdkBackend.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS
|
||||
);
|
||||
this.messageHandler?.flushText();
|
||||
this.messageHandler?.drainBuffers();
|
||||
this.messageHandler = null;
|
||||
await this.waitForSessionUpdateQuiet(
|
||||
AcpSdkBackend.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS,
|
||||
@@ -235,7 +235,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
AcpSdkBackend.UPDATE_QUIET_PERIOD_MS,
|
||||
AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS
|
||||
);
|
||||
this.messageHandler?.flushText();
|
||||
this.messageHandler?.drainBuffers();
|
||||
try {
|
||||
if (stopReason) {
|
||||
onUpdate({ type: 'turn_complete', stopReason });
|
||||
@@ -318,7 +318,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.transport) return;
|
||||
this.messageHandler?.flushText();
|
||||
this.messageHandler?.drainBuffers();
|
||||
this.messageHandler = null;
|
||||
this.activeSessionId = null;
|
||||
this.isProcessingMessage = false;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Stub out `@/configuration` so the helper considers our tmpdir HAPI-managed.
|
||||
// Tests that exercise the non-managed branch override happyHomeDir explicitly.
|
||||
vi.mock('@/configuration', () => ({
|
||||
configuration: {
|
||||
get happyHomeDir(): string {
|
||||
return (globalThis as { __hapiHomeStub?: string }).__hapiHomeStub ?? tmpdir();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// `@/ui/logger` reads `configuration.logsDir` at module load. We only need the
|
||||
// .debug / .warn surface here, so substitute spy shims — tests can assert
|
||||
// against `loggerMock.warn` to lock in the warn-on-failure contract.
|
||||
// `vi.hoisted` ensures the mocks exist when the hoisted `vi.mock` factory runs.
|
||||
const loggerMock = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn()
|
||||
}));
|
||||
vi.mock('@/ui/logger', () => ({ logger: loggerMock }));
|
||||
|
||||
import { ensureOpencodeHookPlugin } from './hookPlugin';
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function setHapiHome(value: string): void {
|
||||
(globalThis as { __hapiHomeStub?: string }).__hapiHomeStub = value;
|
||||
}
|
||||
|
||||
function resetHapiHome(): void {
|
||||
delete (globalThis as { __hapiHomeStub?: string }).__hapiHomeStub;
|
||||
}
|
||||
|
||||
describe('buildPluginSource (via ensureOpencodeHookPlugin)', () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = makeTempDir('hapi-hookplugin-src-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
resetHapiHome();
|
||||
});
|
||||
|
||||
it('emits real newlines, not the literal two-character escape', () => {
|
||||
const pluginPath = ensureOpencodeHookPlugin(tempRoot, 'http://127.0.0.1:1/hook', 'tok');
|
||||
const source = readFileSync(pluginPath, 'utf-8');
|
||||
// Regression for the bug where `.join('\\n')` produced a single-line
|
||||
// file riddled with literal `\n` sequences and was silently dropped by
|
||||
// opencode's plugin loader as a syntax error.
|
||||
expect(source).not.toMatch(/\\n/);
|
||||
expect(source.split('\n').length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('encodes hook url and token without injection-via-quote', () => {
|
||||
const evilToken = 'a"; process.exit(1); //';
|
||||
const pluginPath = ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', evilToken);
|
||||
const source = readFileSync(pluginPath, 'utf-8');
|
||||
// The token must be JSON-escaped — embedding the raw string would let
|
||||
// a malicious caller terminate the literal and inject JS into the
|
||||
// generated plugin.
|
||||
expect(source).toContain(JSON.stringify(evilToken));
|
||||
expect(source).not.toContain(`= "${evilToken}";`);
|
||||
});
|
||||
|
||||
it('preserves the file unchanged when called with identical inputs', () => {
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
const first = readFileSync(join(tempRoot, 'plugins', 'hapi-hook.ts'));
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
const second = readFileSync(join(tempRoot, 'plugins', 'hapi-hook.ts'));
|
||||
expect(second.equals(first)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensurePluginRuntime (via ensureOpencodeHookPlugin)', () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = makeTempDir('hapi-hookplugin-rt-');
|
||||
loggerMock.warn.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
resetHapiHome();
|
||||
});
|
||||
|
||||
it('writes a minimal package.json pinned to a tested @opencode-ai/plugin version', () => {
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(tempRoot, 'package.json'), 'utf-8'));
|
||||
// Pinned (`^1.14.0`) rather than `*` — a wildcard would let a registry
|
||||
// change pull a moving target into a code-execution-adjacent path.
|
||||
const declared = pkg.dependencies['@opencode-ai/plugin'];
|
||||
expect(declared).toMatch(/^[\^~]?\d/);
|
||||
expect(declared).not.toBe('*');
|
||||
});
|
||||
|
||||
it('preserves an existing package.json that already declares @opencode-ai/plugin', () => {
|
||||
const existing = JSON.stringify({
|
||||
dependencies: { '@opencode-ai/plugin': '1.14.30', somethingElse: '^2.0.0' }
|
||||
}, null, 2) + '\n';
|
||||
writeFileSync(join(tempRoot, 'package.json'), existing, 'utf-8');
|
||||
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
|
||||
const actual = readFileSync(join(tempRoot, 'package.json'), 'utf-8');
|
||||
expect(actual).toBe(existing);
|
||||
});
|
||||
|
||||
it('overwrites an existing package.json that does NOT declare @opencode-ai/plugin', () => {
|
||||
// Regression: short-circuiting on plain presence would silently re-create
|
||||
// the broken state — package.json sits there but plugin discovery fails.
|
||||
const unrelated = JSON.stringify({
|
||||
dependencies: { 'some-other-package': '^1.0.0' }
|
||||
}, null, 2) + '\n';
|
||||
writeFileSync(join(tempRoot, 'package.json'), unrelated, 'utf-8');
|
||||
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(tempRoot, 'package.json'), 'utf-8'));
|
||||
expect(pkg.dependencies['@opencode-ai/plugin']).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT write to a non-HAPI-managed dir (user-supplied OPENCODE_CONFIG_DIR)', () => {
|
||||
// Simulate a user pointing OPENCODE_CONFIG_DIR at their own ~/.config/opencode.
|
||||
// Even though it's empty, HAPI must not pollute it with a placeholder.
|
||||
const userOwned = makeTempDir('hapi-not-managed-');
|
||||
try {
|
||||
setHapiHome(makeTempDir('hapi-home-elsewhere-'));
|
||||
|
||||
ensureOpencodeHookPlugin(userOwned, 'http://h/hook', 't');
|
||||
|
||||
expect(existsSync(join(userOwned, 'package.json'))).toBe(false);
|
||||
// Plugin file is still written — that's HAPI's contract — but the
|
||||
// package.json side effect is gated.
|
||||
expect(existsSync(join(userOwned, 'plugins', 'hapi-hook.ts'))).toBe(true);
|
||||
} finally {
|
||||
rmSync(userOwned, { recursive: true, force: true });
|
||||
resetHapiHome();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not touch node_modules or package-lock.json (opencode materializes them)', () => {
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
|
||||
expect(existsSync(join(tempRoot, 'node_modules'))).toBe(false);
|
||||
expect(existsSync(join(tempRoot, 'package-lock.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves existing node_modules and package-lock.json across launches', () => {
|
||||
// Real-world: opencode has run once, installed deps, and we are launching
|
||||
// again. The previous install's artifacts must survive our write.
|
||||
const placeholderNm = join(tempRoot, 'node_modules');
|
||||
const placeholderLock = join(tempRoot, 'package-lock.json');
|
||||
// Use directories/files we can checksum after the call.
|
||||
writeFileSync(placeholderLock, '{"name":"sentinel"}', 'utf-8');
|
||||
const lockBefore = readFileSync(placeholderLock, 'utf-8');
|
||||
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
|
||||
expect(readFileSync(placeholderLock, 'utf-8')).toBe(lockBefore);
|
||||
// node_modules wasn't pre-staged in this case, so it should still be absent.
|
||||
expect(existsSync(placeholderNm)).toBe(false);
|
||||
});
|
||||
|
||||
it('emits a warn (not a throw) when package.json cannot be written', async () => {
|
||||
// Pre-create a directory named `package.json` inside rootPath. The
|
||||
// subsequent writeFileSync will throw EISDIR / EPERM on every
|
||||
// platform. The launcher must keep going (scanner channel #589 is
|
||||
// the documented fallback) and surface the failure via logger.warn.
|
||||
const fs = await import('node:fs');
|
||||
fs.mkdirSync(join(tempRoot, 'package.json'));
|
||||
|
||||
// Must not throw.
|
||||
expect(() => ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't')).not.toThrow();
|
||||
|
||||
// Plugin file should still be written (that path is independent).
|
||||
expect(existsSync(join(tempRoot, 'plugins', 'hapi-hook.ts'))).toBe(true);
|
||||
|
||||
// The failure must be surfaced — silent failure was the pre-#589
|
||||
// bug shape we are explicitly *not* repeating.
|
||||
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.warn.mock.calls[0][0]).toMatch(/Failed to materialize/);
|
||||
});
|
||||
|
||||
it('does not warn on the happy path', () => {
|
||||
ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't');
|
||||
expect(loggerMock.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { isAbsolute, join, relative } from 'node:path';
|
||||
import { logger } from '@/ui/logger';
|
||||
import { configuration } from '@/configuration';
|
||||
|
||||
const PLUGIN_FILENAME = 'hapi-hook.ts';
|
||||
const PACKAGE_JSON_FILENAME = 'package.json';
|
||||
const PLUGIN_PACKAGE = '@opencode-ai/plugin';
|
||||
// Pinned to the major version HAPI has been validated against (opencode
|
||||
// 1.14.x). Using '*' would let an unrelated registry change pull a moving
|
||||
// target into a code-execution path (the plugin runtime executes inside
|
||||
// opencode with the session hook URL + token in scope).
|
||||
const PLUGIN_PACKAGE_VERSION = '^1.14.0';
|
||||
|
||||
function buildPluginSource(hookUrl: string, token: string): string {
|
||||
const escapedUrl = JSON.stringify(hookUrl);
|
||||
@@ -107,16 +116,105 @@ function buildPluginSource(hookUrl: string, token: string): string {
|
||||
' };',
|
||||
'};',
|
||||
''
|
||||
].join('\\n');
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolvePluginDir(rootPath: string): string {
|
||||
return join(rootPath, 'plugins');
|
||||
}
|
||||
|
||||
/**
|
||||
* `rootPath` is a HAPI-managed directory only when it lives under
|
||||
* `configuration.happyHomeDir` (the default `OPENCODE_CONFIG_DIR` we
|
||||
* synthesize per-session). If a user has exported `OPENCODE_CONFIG_DIR`
|
||||
* pointing at e.g. their own `~/.config/opencode`, we must not pollute it
|
||||
* with a placeholder `package.json`.
|
||||
*/
|
||||
function isHapiManagedDir(rootPath: string): boolean {
|
||||
const home = configuration.happyHomeDir;
|
||||
if (!home) {
|
||||
return false;
|
||||
}
|
||||
const rel = relative(home, rootPath);
|
||||
// `relative` returns a path that does NOT start with '..' and is not
|
||||
// absolute when rootPath is inside home. On Windows a cross-volume
|
||||
// input (e.g. home=`C:\\hapi`, rootPath=`D:\\…`) makes `relative`
|
||||
// return the absolute `D:\\…` verbatim — `startsWith('/')` would miss
|
||||
// that, so use `isAbsolute` which covers both POSIX `/` and win32
|
||||
// `<letter>:\\`.
|
||||
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
function hasDeclaredPluginPackage(packageJsonPath: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
return Boolean(parsed.dependencies?.[PLUGIN_PACKAGE] ?? parsed.devDependencies?.[PLUGIN_PACKAGE]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMinimalPackageJson(): string {
|
||||
return `${JSON.stringify({
|
||||
dependencies: { [PLUGIN_PACKAGE]: PLUGIN_PACKAGE_VERSION }
|
||||
}, null, 2)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the opencode runtime can resolve `@opencode-ai/plugin` from our
|
||||
* isolated config dir. Since opencode 1.14.x the plugin loader is a separate
|
||||
* npm package, and `<configDir>/plugins/*.ts` files are only evaluated when
|
||||
* that package is resolvable from `<configDir>`. HAPI's per-session
|
||||
* OPENCODE_CONFIG_DIR is otherwise an empty directory, so plugin discovery
|
||||
* never even attempts to load hapi-hook.ts.
|
||||
*
|
||||
* Writing a minimal `package.json` declaring the dependency is enough:
|
||||
* opencode itself materializes `node_modules` and `package-lock.json` on
|
||||
* the next launch (the same install path `opencode plugin install`
|
||||
* follows). No symlinks, no install spawn, no cross-platform branches —
|
||||
* just a one-line dependency declaration.
|
||||
*
|
||||
* Guards against three failure modes:
|
||||
* - **Non-managed dir**: when the caller pointed OPENCODE_CONFIG_DIR at a
|
||||
* directory outside `happyHomeDir` (e.g. the user's global opencode
|
||||
* config) we leave it alone — that filesystem is not ours to mutate.
|
||||
* - **Existing package.json missing our dep**: parse it and only short-
|
||||
* circuit when `@opencode-ai/plugin` is already declared. Otherwise
|
||||
* overwrite the placeholder so plugin discovery actually works.
|
||||
* - **Write failure**: log and continue; the scanner channel restored in
|
||||
* upstream #589 still carries messages, so a non-writable cfg dir is
|
||||
* degraded but not fatal.
|
||||
*/
|
||||
function ensurePluginRuntime(rootPath: string): void {
|
||||
if (!isHapiManagedDir(rootPath)) {
|
||||
logger.debug(`[opencode-hook] Skipping plugin runtime materialization for non-HAPI dir: ${rootPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJsonPath = join(rootPath, PACKAGE_JSON_FILENAME);
|
||||
if (existsSync(packageJsonPath)) {
|
||||
if (hasDeclaredPluginPackage(packageJsonPath)) {
|
||||
// Existing file already declares the plugin (likely opencode-
|
||||
// installed with a matching lock file). Leave it untouched.
|
||||
return;
|
||||
}
|
||||
logger.debug(`[opencode-hook] package.json exists at ${packageJsonPath} but does not declare ${PLUGIN_PACKAGE}; overwriting placeholder.`);
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(packageJsonPath, buildMinimalPackageJson(), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.warn(`[opencode-hook] Failed to materialize ${packageJsonPath}; the hook plugin channel may stay inert. Storage scanner remains as fallback. Error: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureOpencodeHookPlugin(rootPath: string, hookUrl: string, token: string): string {
|
||||
const pluginDir = resolvePluginDir(rootPath);
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
ensurePluginRuntime(rootPath);
|
||||
|
||||
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
|
||||
const nextSource = buildPluginSource(hookUrl, token);
|
||||
|
||||
Reference in New Issue
Block a user