mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-09 07:29:51 +00:00
feat(cli): bridge OpenCode native compaction via internal REST API (#1252)
* feat(cli): allocate a loopback port for the OpenCode ACP subprocess
opencode does not announce which port it bound when launched with
--port 0 (verified: not present in stdout/stderr even at DEBUG level),
so pick a free loopback port ourselves and pass it explicitly via
--port/--hostname. This makes the ACP subprocess's internal HTTP API
reachable at a known baseUrl for follow-up work (native /compact
bridging).
* feat(cli): add REST bridge for OpenCode native session compaction
opencode's ACP method table has no session/compact RPC, but the
opencode acp subprocess also runs an internal HTTP API. That API's
POST /api/session/:id/compact route is an unimplemented v2 stub
(always 503); the route that actually performs native AI compaction
is the legacy POST /session/:id/summarize, which requires providerID
and modelID in its payload. This adds a small client for that route
plus a helper to split ACP's combined "provider/model" wire id.
Not wired into the slash-command flow yet.
* feat(cli): trigger native OpenCode compaction from /compact
Wires the REST bridge into the OpenCode slash-command flow so /compact
performs real context compaction instead of returning a "not yet
supported" message.
- slashCommands.ts: /compact now resolves to its own `kind: 'compact'`
(the synchronous 'handled' shape can't carry an async REST round
trip). /clear is unchanged.
- opencodeRemoteLauncher.ts: registers a compact trigger callback once
the ACP backend + internal HTTP baseUrl are ready, reading the
session's current provider/model on every call so it reflects
inline model switches. A `runExclusive` promise-chain mutex
serializes the compact trigger against `backend.prompt()` so a
still-in-flight prompt and a `/compact` sent moments later can't
race against the same OpenCode session concurrently, in either
arrival order.
- runOpencode.ts: on /compact, emits "Compaction started" as a session
event (the same `sendSessionEvent({ type: 'message', ... })` status-
line channel Claude/Codex already use for compaction and other
transient state, rather than a chat message), awaits the bridge with
no artificial timeout (compaction on a reasoning model can take
90s+), then emits "Compaction completed" or "Compaction failed:
<reason>" the same way. Falls back to the previous not-yet-supported
chat message when no trigger is registered (local mode has no ACP
backend, so this is unreachable there today). If the user cancels
the message while compaction is queued or in flight, the eventual
result is suppressed instead of surfacing a stray status message
for an action the user already considered cancelled (aborting the
in-flight HTTP call itself is left as follow-up scope).
- help text now notes /compact is remote-sessions only, since local
mode has no ACP backend to bridge through.
Also fixes a runOpencode.test.ts fixture gap: the mocked
OpencodeSession was missing onThinkingChange, so any exception in that
call path was silently swallowed by the handler's outer catch instead
of failing the test.
* feat(cli): show compaction summary as a reasoning block
After a native OpenCode compaction succeeds, OpenCode's session
history gains a `role: "user"` marker message (a `{type:"compaction"}`
part with no text) followed by a normal assistant message holding the
actual generated summary in a `text` part. Left alone, neither is
visible in HAPI — the bridge only checked success/failure and never
looked at the resulting messages.
- opencodeCompactBridge.ts: `fetchCompactionSummary()` does one more
GET against the session's message list after a successful compact,
finds the most recent `type:"compaction"` marker, and extracts the
following assistant message's text — preferring a match via the
marker's `parentID` (order-safe) and falling back to simple array
adjacency, both gated on `role === 'assistant'` so an unrelated
same-shaped sibling can't be silently misattributed as the summary.
Concatenates every `text` part in case a summary spans more than
one. Never throws: any unexpected shape or request failure just
yields "not found" so the caller can skip showing anything.
- opencodeRemoteLauncher.ts / runOpencode.ts: on a successful compact,
forward the extracted summary as a `{ type: 'reasoning', text, id }`
AgentMessage through the same `handleAgentMessage()` /
`convertAgentMessage()` path OpenCode's own live thought-chunk
streaming already uses, so it renders in the existing collapsible
"Reasoning" block — no new UI component or schema field. The
`role:"user"` marker is never constructed or forwarded at all, so
there's nothing to filter (unlike Claude's compact summary, which
is hidden after the fact via an `isCompactSummary` flag).
* fix(cli): disable Bun's hardcoded fetch timeout for OpenCode compaction
Isolated E2E against a real OpenCode session showed compaction always
failing with "Compaction failed: The operation timed out." after
~250s, even though session/prompt-style unlimited waits were intended.
Root cause: Bun's global fetch() hardcodes an internal ~5 minute
timeout that fires independently of any AbortSignal (or its absence) —
see oven-sh/bun#16682. The only documented workaround is passing the
non-standard `timeout: false` fetch option that Bun itself recognizes.
Cast through a local `BunFetchInit = RequestInit & { timeout?: false }`
type alias rather than `Record<string, unknown>`, so the option stays
structurally checked against the rest of the fetch call's shape.
* fix(cli): serialize /compact through the message queue instead of a mutex
HAPI Bot flagged two Major correctness issues on the PR:
- `/compact` bypassed `MessageQueue2` and entered a `runExclusive` mutex
directly from the user-message handler. If prompt A was in flight and
prompt B already queued behind it, `/compact` sent afterward could
still reach the mutex before B did, running compaction ahead of an
earlier-queued user prompt.
- `compactTriggerRef` (a closure capturing the remote backend) was
never cleared on a remote→local handoff, so `/compact` typed after
switching to local mode could call a stale closure targeting an
already-disposed backend instead of the intended local-mode fallback.
Removes the mutex entirely and routes `/compact` through the same
`messageQueue` regular prompts use (a new `operation?: 'compact'` field
on `OpencodeMode`, dequeued by the launcher's single sequential
consumer loop, which branches to a new `runCompactOperation()` instead
of `backend.prompt()`). Ordering is now enforced by construction (one
queue item in flight at a time) rather than a second bolted-on lock.
Replaces the closure-based `onCompactTriggerReady` callback with a
boolean-flag `onCompactAvailabilityChange`, reset to `false` on every
entry into local mode (cold-start and handoff alike) before the remote
backend is even disposed, so there is no window where the flag says
available but the backend underneath it is gone.
* fix(cli): let /compact's cancel ack fire at dequeue time, not on queue
A follow-up HAPI Bot review caught a Major issue this PR's queue-based
/compact serialization (previous commit) left open: runOpencode.ts
still called session.emitMessagesConsumed manually and synchronously
the instant /compact was pushed onto the queue -- a leftover from
before /compact was routed through the queue at all. That beat
MessageQueue2's automatic dequeue-time ack (onBatchConsumed, wired in
sessionBase.ts, already used by every regular prompt) to the hub, so a
/compact sitting behind other queued prompts was marked "invoked"
immediately and could never actually be cancelled from the UI.
Removes the manual ack; the queue's own dequeue-time ack now covers
/compact exactly like any other queued operation. Adds a deterministic
test for the reported scenario: prompt A generating, /compact queued
behind it, cancelled before A finishes -- the compact REST bridge must
never be called.
* fix(cli): suppress live ACP updates while a compact REST call runs
Found via live use: OpenCode keeps streaming session/update
notifications (thought chunks, etc.) over the ACP transport while
/compact's REST call runs outside prompt(), and
AcpSdkBackend.handleSessionUpdate forwarded them unconditionally to
whatever messageHandler was still installed from the last real prompt
turn -- rendering the compaction summary a second time as a plain
assistant message, alongside the explicit Reasoning block this PR
already sends for the same content.
Adds a narrow, opt-in AcpSdkBackend.suppressUpdatesDuring() (shared
with Gemini, but a pure addition with no behavior change for existing
prompt() callers) that temporarily swaps out the message handler for
the duration of an async callback, restoring it afterward. Wraps the
compact bridge's REST call with it so the live stream produces no
output during that window -- the Reasoning block built from the
explicit GET response becomes the only place the summary appears.
* fix(cli): let Stop/switch-to-local interrupt an in-flight /compact REST call
triggerOpencodeCompact's HTTP request is intentionally unbounded (a
real compaction can take minutes), but the launcher awaited it with no
way to interrupt it once dequeued and running. handleAbort() only
cancels the ACP prompt() turn and resets the queue -- neither touches
this raw HTTP call -- so Stop (and switch-to-local, which routes
through the same handler) stayed blocked until the request settled on
its own: a stale "Compaction completed/failed" could still surface
afterward, queued prompts behind it were delayed, and remote->local
handoff couldn't proceed.
Add a per-call AbortController (compactAbortController) that
handleAbort() aborts before cancelling the prompt, and thread it
through triggerOpencodeCompact's new optional `signal` (kept separate
from the existing timeout:false Bun workaround, which stays
unconditional). An aborted call now folds into the same isCancelled()
check that already suppresses a stale result for the existing
queue-cancel race, so either kind of interruption behaves the same
way.
* fix(cli): structurally close remaining /compact abort/lifecycle races
Two more narrow races surfaced in review, both symptoms of ad hoc
per-site fixes rather than a shared mechanism:
1. The signal threaded through triggerOpencodeCompact (the POST) did
not also reach fetchCompactionSummary (the GET runCompactOperation
makes right after it), so Stop/switch-to-local could still block on
that call alone. Introduce OpencodeCompactCallOpts with `signal`
required (not optional) so every HTTP step opencodeCompactBridge.ts
makes on behalf of one /compact operation is forced by the compiler
to accept it, not left to remembering to wire it in per call site.
2. /compact availability (runOpencode.ts's compactSupported flag) was
only reset to false on the *next* local-mode entry (loop.ts's
runLocal callback), leaving a window between "switch/exit was
requested" and "local mode actually started" where a /compact
arriving mid-transition could still queue, and -- since local mode
hands straight back to remote when it finds a non-empty queue --
run anyway despite the user having already left remote mode. Add an
onLeavingRemote() hook to RemoteLauncherBase (no-op default, so the
other six flavors built on it are unaffected) called synchronously
as the very first action of requestExit() -- closing the race at
its source -- and again unconditionally in start()'s finally block
as a backstop for exit paths that never call requestExit() at all
(an exception thrown from runMainLoop, for instance).
OpencodeRemoteLauncher overrides it to flip availability false;
loop.ts's local-entry reset is removed as redundant now that
leaving remote is what's authoritative.
* fix(cli): create compactAbortController before the inline model/effort switch
A hostile-review whole-feature sweep of the /compact abort/lifecycle
surface (round 5) found that the dequeue loop applied the per-batch
inline model/effort switch (real async ACP round-trips that yield to
the event loop) *before* branching into runCompactOperation(), which
is where compactAbortController used to get created. An abort firing
during that switch hit a still-null controller (a no-op), and by the
time the switch resolved and runCompactOperation() created a fresh
one, the abort was forgotten -- the compact's unbounded REST call
would then run to completion despite the user having already pressed
Stop/switch/exit.
Move controller creation to the top of the loop iteration, as soon as
the batch is known to be a compact operation, and pass it into
runCompactOperation() rather than having that function create its
own. Also: soften an overstated doc comment about what the required
`signal` field actually guarantees, and add a regression test locking
in that two sequential compact operations each get an independent
controller (no leak or cross-clearing).
* fix(cli): drain quietly before restoring the handler in suppressUpdatesDuring
A 5th PR-review round found that suppressUpdatesDuring restored the
suppressed messageHandler the instant its callback settled, but
aborting the client-side HTTP call (e.g. OpenCode's compact bridge
aborting via compactAbortController) does not necessarily stop the
agent from continuing that operation server-side -- session/update is
a separate notification channel from the HTTP request's lifecycle.
A late straggler notification from a still-running server-side
operation could leak straight into the restored handler (or into a
new one prompt() installs right after).
Reuse the same quiet-drain prompt() already runs before installing a
new handler for the next turn (waitForSessionUpdateQuiet with the
PRE_PROMPT_* constants) -- same class of race, same validated
mechanism, just on the way back in instead of the way out. No new
state or Stop-vs-switch branching: the wait is internal to
suppressUpdatesDuring and the handler stays suppressed throughout it.
* fix(cli): queue /compact during remote-mode initialization instead of rejecting it
compactSupported alone conflated two different situations: a
genuinely local-mode session (compact fundamentally can't run) versus
a session that's already in remote mode but hasn't finished ACP
initialize + session load/new yet (onCompactAvailabilityChange(true)
hasn't fired yet, but will shortly). A regular prompt sent in that
same startup window queues normally and just waits its turn; /compact
sent in the identical window instead got an immediate
not-yet-supported reply.
Check sessionWrapperRef.current?.mode (the actual OpencodeSession
instance's mode, synced synchronously by onModeChange before either
launcher starts) alongside compactSupported: only genuinely local mode
now gets the not-yet-supported reply. A session already in remote mode
but still initializing queues /compact exactly like a prompt and lets
it settle into its real FIFO position once the launcher is ready.
* fix(cli): don't let the remote-init /compact queuing fix reopen the teardown race
A hostile-review whole-feature sweep found that gating /compact
queuing on sessionWrapperRef.current?.mode alone (26344118) fixed the
startup window but silently reopened the exact race
OpencodeRemoteLauncher's onLeavingRemote() exists to close: mode stays
'remote' for the entire teardown window too (it only flips back to
'local' once runMainLoop() fully unwinds), so a /compact arriving
after switch/exit was requested -- while compactSupported has already
gone false -- queued anyway instead of getting rejected, and could
still run once local mode bounced back to remote to drain a non-empty
queue.
Add compactTeardownInProgress, true from the moment
onCompactAvailabilityChange(false) fires (which -- since the old
reset-on-local-entry was removed -- only ever means "leaving remote",
never "not ready yet") until the session next re-enters remote mode.
Wrap the onModeChange callback opencodeLoop already receives to reset
it back to false on that re-entry. The existing "stops queuing /compact
once availability is reset to false" test didn't catch this because
its mock never set mode to 'remote' during the simulated teardown,
unlike real production timing -- fixed to match.
* fix(cli): keep the dequeue loop blocked on a compact until it really finishes on plain Stop
A 6th PR-review round rejected the quiet-drain mitigation from the
previous round (bounded at ~1.2s) and asked for the originally
proposed fix instead: quiet-drain cannot guarantee a multi-minute
server-side compaction has actually finished, since session/update is
a separate notification channel from the aborted HTTP request's
lifecycle. Unconditionally aborting compactAbortController on any
abort (the previous fix) let the dequeue loop move on to the next
queued prompt while the agent could still be compacting the same
OpenCode session server-side -- breaking the core invariant this
feature's whole queue-based redesign depends on: compact and a prompt
must never touch the same session concurrently.
handleAbort() now takes a leavingRemote parameter. Plain Stop
(leavingRemote=false, the default) only sets compactResultSuppressed
-- the eventual result gets hidden, but compactAbortController is left
alone, so runCompactOperation()'s own awaits keep blocking the dequeue
loop until the real HTTP response arrives, i.e. until the server
actually finishes. Switch-to-local/exit (leavingRemote=true) still
abort the controller for real, since cleanup() disconnects the whole
ACP subprocess right after regardless -- there's no shared session left
to protect there, and the responsiveness fixed in an earlier round
still matters for that path.
The quiet-drain from the previous round (AcpSdkBackend.suppressUpdatesDuring)
is left in place -- it still helps for a compaction that finishes
quickly and for trailing session/update stragglers right after a real
completion, it's just no longer the thing plain Stop relies on for
correctness.
* fix(cli): close 3 gaps a hostile-review pass expected the next bot round to flag
Pre-emptively addresses findings a hostile-review final pass on
65729bb7 judged likely for the next external bot round, since a
communication round-trip costs more than fixing them now:
1. No dedicated test existed for the Stop/switch-to-local RPC-overlap
message-ordering fix (handleAbort() re-reading compactAbortController's
abort state instead of a stale snapshot). The test harness has no way
to observe MessageBuffer/Ink content, so the decision logic that
picks the status message and whether to clear `thinking` is extracted
into a pure, exported selectAbortStatusMessage() function and unit
tested directly against all four (hasCompactInFlight, leavingRemote,
compactAborted) combinations, including the exact overlap case.
2. No test verified compactResultSuppressed doesn't leak between two
sequential compact operations. Added a regression test: a Stop-suppressed
compact #1 finishing for real must not silence a normally-completed
compact #2 queued after it.
3. The "Stop requested — waiting..." status message didn't tell the user
how to actually leave the session (switch-to-local/exit) while a
compaction is deliberately left running. Appended that guidance.
* fix(cli): skip a compact cancelled before its request ever went out
A plain Stop landing during the inline model/effort switch that
precedes a compact batch only set compactResultSuppressed; it never
stopped the dequeue loop from calling runCompactOperation()
unconditionally once that switch resolved, so a cancelled-before-start
compact would still fire a brand new REST request and block the loop
for however long that call takes.
Skip starting the operation when compactResultSuppressed is set and
the controller was never actually aborted (plain Stop leaves the
signal alone by design). Deliberately excludes the
compactAbortController.signal.aborted case (switch/exit) and
isLocalIdCancelled: both must keep falling through to
runCompactOperation() as before, per Round 5's and the
isLocalIdCancelled suite's existing coverage.
* fix(cli): also skip a compact cancelled-before-start via isLocalIdCancelled
Round 7's pre-start skip check only covered compactResultSuppressed
(plain Stop), deliberately leaving isLocalIdCancelled out so as not
to disturb the "Compaction started is never suppressed" tests that
predated that check. But isLocalIdCancelled's backing Set can only
ever be populated during the brief ack-vs-hub-DB-write race before a
queued item's REST call is sent, never while it's actually running
(see runOpencode.ts's cancelledBeforeEnqueue doc comment) — so a true
result here unconditionally means the same "cancelled before it ever
went out" situation Round 7 already handles for plain Stop, and
deserves the same treatment: skip starting the operation instead of
sending "Compaction started" for a request already known to be
discarded.
Updates the two tests that had encoded the old "started is never
suppressed" behavior for this specific signal to their corrected
expectation, and removes a no-longer-consumed mockImplementationOnce
that would otherwise have leaked its failure response into the next
test that actually calls the REST bridge.
* fix(cli): don't resurrect /compact availability after a startup-time switch/exit
onCompactAvailabilityChange(true) fired unconditionally right after
newSession/loadSession resolved, with no way to know a terminal
switch-to-local/exit had already run during that pending ACP round
trip (RemoteLauncherBase.requestExit() sets shouldExit synchronously
before awaiting its handler, so the flag is already accurate at that
point). runOpencode.ts's compactSupported/compactTeardownInProgress
gate treats compactSupported flipping true as reason enough to ignore
compactTeardownInProgress entirely, so this belated true could let a
/compact arriving right after slip into the queue mid-teardown.
Guard the call with the same shouldExit flag requestExit() already
set. The race can only be reached via the terminal UI's onExit/
onSwitchToLocal callbacks (wired up before runMainLoop starts, well
before the RPC 'abort'/'switch' handlers exist), so the regression
test forces isTTY and captures ink's render() props to invoke them
directly.
* fix(cli): clear the hub's queued-thinking grace when a compact is skipped pre-start
The cancelledBeforeStart skip path never calls session.onThinkingChange(true)
(the whole point of skipping), but also never told the hub the queued
item was done. The hub's 15s queued-thinking grace (markMessageQueued,
sessionCache.ts) keeps thinking pinned true regardless of keepalives
until a messages-consumed ack with clearQueuedThinkingGrace arrives,
so the web UI spinner could sit stuck for the full grace window.
Applies the same pattern already used by runOpencode.ts's synchronous
slash.kind === 'handled' path (e.g. /model): an emitMessagesConsumed
ack with clearQueuedThinkingGrace, then an immediate thinking=false
keepalive. This is additive to, not a replacement for, the queue's own
unflagged onBatchConsumed ack — a second ack for an already-invoked
localId is a no-op on the hub's first-write-wins protocol, and
clearQueuedThinkingGrace is keyed by session, not localId, so both are
idempotent.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const constructorCalls: Array<{ command: string; args?: string[]; env?: Record<string, string> }> = [];
|
||||
|
||||
vi.mock('@/agent/backends/acp', () => ({
|
||||
AcpSdkBackend: vi.fn().mockImplementation(function (
|
||||
this: unknown,
|
||||
opts: { command: string; args?: string[]; env?: Record<string, string> }
|
||||
) {
|
||||
constructorCalls.push(opts);
|
||||
return { __opts: opts };
|
||||
})
|
||||
}));
|
||||
|
||||
import { allocateFreePort, createOpencodeBackend } from './opencodeBackend';
|
||||
|
||||
describe('allocateFreePort', () => {
|
||||
it('resolves a bindable loopback port number', async () => {
|
||||
const port = await allocateFreePort('127.0.0.1');
|
||||
expect(typeof port).toBe('number');
|
||||
expect(Number.isInteger(port)).toBe(true);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(port).toBeLessThan(65536);
|
||||
});
|
||||
|
||||
it('releases the port so it can be reused by a later caller', async () => {
|
||||
// Each call must close its probe socket before resolving, otherwise a
|
||||
// consumer that immediately tries to bind the returned port (e.g. the
|
||||
// spawned `opencode acp --port <port>` process) would collide with it.
|
||||
const first = await allocateFreePort('127.0.0.1');
|
||||
const second = await allocateFreePort('127.0.0.1');
|
||||
expect(typeof second).toBe('number');
|
||||
// Not asserting first !== second (OS may reuse immediately-freed ports),
|
||||
// only that a second allocation does not hang or throw EADDRINUSE.
|
||||
expect(first).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOpencodeBackend', () => {
|
||||
beforeEach(() => {
|
||||
constructorCalls.length = 0;
|
||||
});
|
||||
|
||||
it('passes --port and --hostname args when provided', () => {
|
||||
createOpencodeBackend({ cwd: '/tmp/x', port: 5555, hostname: '127.0.0.1' });
|
||||
expect(constructorCalls[0]?.args).toEqual([
|
||||
'acp', '--cwd', '/tmp/x', '--port', '5555', '--hostname', '127.0.0.1'
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits --port/--hostname when not provided (backward compatible)', () => {
|
||||
createOpencodeBackend({ cwd: '/tmp/x' });
|
||||
expect(constructorCalls[0]?.args).toEqual(['acp', '--cwd', '/tmp/x']);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { AcpSdkBackend } from '@/agent/backends/acp';
|
||||
import { buildOpencodeEnv } from './config';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
@@ -12,11 +13,52 @@ function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves a free TCP port on the given loopback host by binding an
|
||||
* ephemeral probe socket (`listen(0)`) and immediately closing it before
|
||||
* resolving. `opencode acp` does not announce the port it actually bound
|
||||
* when launched with `--port 0` (verified 2026-07-30 — no port appears in
|
||||
* stdout/stderr even at DEBUG log level), so HAPI must pick the port itself
|
||||
* and hand it to the subprocess explicitly via `--port`. There is a
|
||||
* theoretical reuse race between this function releasing the port and the
|
||||
* subprocess binding it, but both sides are loopback-only local processes,
|
||||
* so the practical risk is low.
|
||||
*/
|
||||
export function allocateFreePort(hostname = '127.0.0.1'): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, hostname, () => {
|
||||
const address = probe.address();
|
||||
const port = address && typeof address === 'object' ? address.port : null;
|
||||
probe.close((closeError) => {
|
||||
if (closeError) {
|
||||
reject(closeError);
|
||||
return;
|
||||
}
|
||||
if (port === null) {
|
||||
reject(new Error('Failed to allocate a free port for the OpenCode ACP server'));
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createOpencodeBackend(opts: {
|
||||
cwd?: string;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}): AcpSdkBackend {
|
||||
const env = buildOpencodeEnv();
|
||||
const args = ['acp', '--cwd', opts.cwd ?? getInvokedCwd()];
|
||||
if (opts.port !== undefined) {
|
||||
args.push('--port', String(opts.port));
|
||||
}
|
||||
if (opts.hostname !== undefined) {
|
||||
args.push('--hostname', opts.hostname);
|
||||
}
|
||||
|
||||
return new AcpSdkBackend({
|
||||
command: 'opencode',
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fetchCompactionSummary, splitProviderModel, triggerOpencodeCompact } from './opencodeCompactBridge';
|
||||
|
||||
// `signal` is a required field (see OpencodeCompactCallOpts's doc comment) —
|
||||
// most tests below don't exercise abort behavior at all, so this is a
|
||||
// signal that's simply never aborted, just satisfying the type.
|
||||
const noSignal = new AbortController().signal;
|
||||
|
||||
describe('splitProviderModel', () => {
|
||||
it('splits a combined "provider/model" wire id on the first slash', () => {
|
||||
expect(splitProviderModel('ollama/qwen3.6:35b-a3b-q8_0-mtp')).toEqual({
|
||||
providerId: 'ollama',
|
||||
modelId: 'qwen3.6:35b-a3b-q8_0-mtp'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps everything after the first slash as the modelId (model ids may contain slashes)', () => {
|
||||
expect(splitProviderModel('openrouter/anthropic/claude-sonnet-4-5')).toEqual({
|
||||
providerId: 'openrouter',
|
||||
modelId: 'anthropic/claude-sonnet-4-5'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(splitProviderModel(null)).toBeNull();
|
||||
expect(splitProviderModel(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no slash', () => {
|
||||
expect(splitProviderModel('no-slash-here')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a leading or trailing slash (empty provider or model)', () => {
|
||||
expect(splitProviderModel('/model-only')).toBeNull();
|
||||
expect(splitProviderModel('provider-only/')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerOpencodeCompact', () => {
|
||||
it('posts to /session/:id/summarize with the required providerID/modelID payload and no artificial timeout', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
expect(url).toBe('http://127.0.0.1:48273/session/ses_abc/summarize');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
providerID: 'ollama',
|
||||
modelID: 'qwen3.6:35b-a3b-q8_0-mtp'
|
||||
});
|
||||
// `signal` is always attached now (required — see
|
||||
// OpencodeCompactCallOpts), but it must not act as a deadline on
|
||||
// its own: the caller here never aborts it, so the request must
|
||||
// run to completion regardless of how long it legitimately takes
|
||||
// (90s+ verified against SER8, 2026-07-30).
|
||||
expect(init?.signal).toBe(noSignal);
|
||||
// Bun's global fetch() hardcodes a 5-minute idle timeout that
|
||||
// fires even with no AbortSignal at all (verified via isolated
|
||||
// E2E against SER8, 2026-07-30 — a real ~250s compaction call
|
||||
// failed with "The operation timed out"; see oven-sh/bun#16682).
|
||||
// The only documented workaround is this Bun-specific,
|
||||
// non-standard `timeout: false` fetch option — needed regardless
|
||||
// of whether the caller's own `signal` ever fires.
|
||||
expect((init as unknown as { timeout?: boolean })?.timeout).toBe(false);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
|
||||
const result = await triggerOpencodeCompact({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
providerId: 'ollama',
|
||||
modelId: 'qwen3.6:35b-a3b-q8_0-mtp',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a structured failure when the server responds non-ok (e.g. the v2 compact stub 503)', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(
|
||||
JSON.stringify({ _tag: 'ServiceUnavailableError', message: 'Session compact is not available yet', service: 'session.compact' }),
|
||||
{ status: 503 }
|
||||
));
|
||||
|
||||
const result = await triggerOpencodeCompact({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
providerId: 'ollama',
|
||||
modelId: 'qwen3.6:35b-a3b-q8_0-mtp',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain('503');
|
||||
expect(result.error).toContain('not available yet');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a structured failure when the network call throws', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
|
||||
const result = await triggerOpencodeCompact({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
providerId: 'ollama',
|
||||
modelId: 'qwen3.6:35b-a3b-q8_0-mtp',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'ECONNREFUSED' });
|
||||
});
|
||||
|
||||
it('URL-encodes the sessionId in the path', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string) => {
|
||||
expect(url).toBe('http://127.0.0.1:48273/session/ses%20with%20space/summarize');
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
|
||||
await triggerOpencodeCompact({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses with space',
|
||||
providerId: 'ollama',
|
||||
modelId: 'model-x',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards an AbortSignal to fetch when provided, so a caller can interrupt an in-flight request', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.signal).toBe(controller.signal);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
|
||||
const result = await triggerOpencodeCompact({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
providerId: 'ollama',
|
||||
modelId: 'qwen3.6:35b-a3b-q8_0-mtp',
|
||||
fetchImpl,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('resolves with a structured failure (not a hang or uncaught rejection) when the signal aborts mid-request', async () => {
|
||||
const controller = new AbortController();
|
||||
// Mirrors how a real fetch() rejects on abort: the promise only
|
||||
// settles once the signal actually fires, not before.
|
||||
const fetchImpl = vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
});
|
||||
}));
|
||||
|
||||
const resultPromise = triggerOpencodeCompact({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
providerId: 'ollama',
|
||||
modelId: 'qwen3.6:35b-a3b-q8_0-mtp',
|
||||
fetchImpl,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCompactionSummary', () => {
|
||||
it('extracts the text part of the assistant message that follows the compaction marker (matched via parentID)', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string) => {
|
||||
expect(url).toBe('http://127.0.0.1:48273/session/ses_abc/message');
|
||||
return new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_1', role: 'user' }, parts: [{ id: 'prt_1', type: 'text', text: 'hello' }] },
|
||||
{ info: { id: 'msg_2', role: 'assistant' }, parts: [{ id: 'prt_2', type: 'text', text: 'hi there' }] },
|
||||
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
|
||||
{
|
||||
info: { id: 'msg_4', role: 'assistant', parentID: 'msg_3', summary: true },
|
||||
parts: [
|
||||
{ id: 'prt_4a', type: 'step-start' },
|
||||
{ id: 'prt_4b', type: 'reasoning', text: 'thinking about the summary' },
|
||||
{ id: 'prt_4c', type: 'text', text: '## Objective\n- Did the thing' },
|
||||
{ id: 'prt_4d', type: 'step-finish' }
|
||||
]
|
||||
}
|
||||
]), { status: 200 });
|
||||
});
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: true, text: '## Objective\n- Did the thing' });
|
||||
});
|
||||
|
||||
it('falls back to positional adjacency when the assistant message has no parentID', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
|
||||
{ info: { id: 'msg_4', role: 'assistant' }, parts: [{ id: 'prt_4', type: 'text', text: 'summary via positional match' }] }
|
||||
]), { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: true, text: 'summary via positional match' });
|
||||
});
|
||||
|
||||
it('rejects a parentID/positional match whose role is not assistant, even if it happens to carry a text part', async () => {
|
||||
// Both the parentID-linked entry AND the positionally-adjacent entry
|
||||
// have a `type:'text'` part here, but neither is role:'assistant' —
|
||||
// the safe fallback (found:false) must win rather than surfacing
|
||||
// whatever unrelated text these entries happen to carry.
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
|
||||
{ info: { id: 'msg_4', role: 'user', parentID: 'msg_3' }, parts: [{ id: 'prt_4', type: 'text', text: 'not actually a summary' }] }
|
||||
]), { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('concatenates multiple text parts in order instead of only taking the first', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
|
||||
{
|
||||
info: { id: 'msg_4', role: 'assistant', parentID: 'msg_3' },
|
||||
parts: [
|
||||
{ id: 'prt_4a', type: 'text', text: '## Objective\n' },
|
||||
{ id: 'prt_4b', type: 'step-finish' },
|
||||
{ id: 'prt_4c', type: 'text', text: '- Did the thing' }
|
||||
]
|
||||
}
|
||||
]), { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: true, text: '## Objective\n- Did the thing' });
|
||||
});
|
||||
|
||||
it('returns found:false when no compaction marker exists', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_1', role: 'user' }, parts: [{ id: 'prt_1', type: 'text', text: 'hello' }] },
|
||||
{ info: { id: 'msg_2', role: 'assistant' }, parts: [{ id: 'prt_2', type: 'text', text: 'hi' }] }
|
||||
]), { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('returns found:false when the marker is the last message (no following assistant message yet)', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] }
|
||||
]), { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('returns found:false when the following assistant message has no text part', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
|
||||
{ info: { id: 'msg_4', role: 'assistant', parentID: 'msg_3' }, parts: [{ id: 'prt_4', type: 'step-finish' }] }
|
||||
]), { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('returns found:false on a non-ok response', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 500 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('returns found:false when the response is not valid JSON / not an array', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('not json', { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('returns found:false when the network call throws', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('picks the LAST compaction marker when there are multiple (a session may be compacted more than once)', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
|
||||
{ info: { id: 'msg_1', role: 'user' }, parts: [{ id: 'prt_1', type: 'compaction', auto: false }] },
|
||||
{ info: { id: 'msg_2', role: 'assistant', parentID: 'msg_1' }, parts: [{ id: 'prt_2', type: 'text', text: 'first summary' }] },
|
||||
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'text', text: 'more chat' }] },
|
||||
{ info: { id: 'msg_4', role: 'user' }, parts: [{ id: 'prt_4', type: 'compaction', auto: false }] },
|
||||
{ info: { id: 'msg_5', role: 'assistant', parentID: 'msg_4' }, parts: [{ id: 'prt_5', type: 'text', text: 'second summary' }] }
|
||||
]), { status: 200 }));
|
||||
|
||||
const result = await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: noSignal
|
||||
});
|
||||
|
||||
expect(result).toEqual({ found: true, text: 'second summary' });
|
||||
});
|
||||
|
||||
it('forwards an AbortSignal to fetch when provided, so a caller can interrupt an in-flight request', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.signal).toBe(controller.signal);
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
});
|
||||
|
||||
await fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves with found:false (not a hang or uncaught rejection) when the signal aborts mid-request', async () => {
|
||||
// Reproduces the exact gap a PR-review round found: the POST
|
||||
// (triggerOpencodeCompact) had a signal wired through in an earlier
|
||||
// round, but this GET — which runs right after it inside
|
||||
// runCompactOperation() — did not, so Stop/switch-to-local could
|
||||
// still block on this call even after that fix.
|
||||
const controller = new AbortController();
|
||||
const fetchImpl = vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
});
|
||||
}));
|
||||
|
||||
const resultPromise = fetchCompactionSummary({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc',
|
||||
fetchImpl,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
export type OpencodeCompactResult =
|
||||
| { ok: true; summaryText?: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type CompactionSummaryResult =
|
||||
| { found: true; text: string }
|
||||
| { found: false };
|
||||
|
||||
/** Minimal fetch-shaped function signature, kept narrower than `typeof fetch` so tests can pass a plain `vi.fn()` without matching runtime-specific extras (e.g. Bun's `fetch.preconnect`). */
|
||||
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* `RequestInit` extended with Bun's non-standard `timeout` fetch option
|
||||
* (absent from `bun-types` / the standard fetch typings — see the
|
||||
* `triggerOpencodeCompact` doc comment for why it's needed). Narrower than
|
||||
* `Record<string, unknown>` so the cast below can't silently accept an
|
||||
* unrelated typo'd option name.
|
||||
*/
|
||||
type BunFetchInit = RequestInit & { timeout?: false };
|
||||
|
||||
/**
|
||||
* Every OpenCode-side HTTP call `runCompactOperation()` in
|
||||
* opencodeRemoteLauncher.ts makes on behalf of a single /compact operation
|
||||
* must extend this — `signal` is **required**, not optional. That launcher
|
||||
* owns exactly one `AbortController` for the operation's whole lifecycle
|
||||
* (`compactAbortController`, aborted by `handleAbort()` on Stop/switch/exit)
|
||||
* and threads its `.signal` through every step so a user-initiated
|
||||
* interruption actually reaches whichever HTTP call happens to be in flight
|
||||
* at the time.
|
||||
*
|
||||
* This was previously opt-in (`signal?: AbortSignal`) on each function
|
||||
* individually, which is exactly how a real regression happened: a second
|
||||
* PR-review round later found that `triggerOpencodeCompact` (the POST) had
|
||||
* been wired up but `fetchCompactionSummary` (the GET that runs right
|
||||
* after it) had not, because nothing forced it. Making `signal` a required
|
||||
* field of a shared base type means the compiler catches a future third
|
||||
* HTTP step *implemented as a function in this file* without one — it can't
|
||||
* stop someone from bypassing this file entirely with an inline `fetch()`
|
||||
* call in `runCompactOperation()`, so this is a guardrail for the pattern
|
||||
* this file establishes, not an architectural boundary enforced repo-wide.
|
||||
*/
|
||||
export type OpencodeCompactCallOpts = {
|
||||
baseUrl: string;
|
||||
sessionId: string;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits an ACP-reported combined model id (e.g. `"ollama/qwen3.6:35b-a3b-q8_0-mtp"`)
|
||||
* into the separate `providerId`/`modelId` pair required by OpenCode's internal
|
||||
* `POST /session/:id/summarize` payload. Only the first `/` is treated as the
|
||||
* separator — model ids may themselves contain slashes (e.g. OpenRouter-style
|
||||
* `"openrouter/anthropic/claude-sonnet-4-5"`).
|
||||
*/
|
||||
export function splitProviderModel(combined: string | null | undefined): { providerId: string; modelId: string } | null {
|
||||
if (!combined) return null;
|
||||
const separatorIndex = combined.indexOf('/');
|
||||
if (separatorIndex <= 0 || separatorIndex === combined.length - 1) return null;
|
||||
return {
|
||||
providerId: combined.slice(0, separatorIndex),
|
||||
modelId: combined.slice(separatorIndex + 1)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers OpenCode's native AI-compaction for a session by calling the
|
||||
* legacy `POST /session/:id/summarize` route on the `opencode acp`
|
||||
* subprocess's internal HTTP API.
|
||||
*
|
||||
* This is NOT `POST /api/session/:id/compact` — that v2-API route is an
|
||||
* unimplemented stub in opencode 1.18.9 and always returns 503
|
||||
* ("Session compact is not available yet"). `summarize` is the route that
|
||||
* actually performs native AI compaction (verified 2026-07-30: triggering it
|
||||
* appends a real `{"type":"compaction"}` message part to the session, and
|
||||
* streams `agent_thought_chunk` ACP notifications while the model works).
|
||||
*
|
||||
* `providerID`/`modelID` are required by the endpoint (400 if omitted).
|
||||
* The response can legitimately take several minutes to arrive for slow
|
||||
* models, so by default no deadline is applied here, mirroring how
|
||||
* `AcpSdkBackend.prompt()` uses `timeoutMs: Infinity` for `session/prompt`.
|
||||
* `signal` (required — see `OpencodeCompactCallOpts`) is a caller-driven
|
||||
* abort, not a deadline, so it's orthogonal to the Bun timeout workaround
|
||||
* below (both apply at once).
|
||||
*
|
||||
* Omitting the `timeout: false` option below is NOT enough under Bun: Bun's
|
||||
* global `fetch()` hardcodes its own idle timeout (~5 minutes) that fires
|
||||
* independently of any AbortSignal (verified 2026-07-30 via isolated E2E
|
||||
* against SER8 — a real ~250s compaction call was killed client-side with
|
||||
* "The operation timed out" even with no signal attached; see upstream
|
||||
* report oven-sh/bun#16682). The only documented workaround is the
|
||||
* non-standard `timeout: false` fetch option Bun itself recognizes (absent
|
||||
* from the standard `RequestInit` typings, hence the cast below) — kept
|
||||
* unconditionally regardless of `signal` also being set.
|
||||
*/
|
||||
export async function triggerOpencodeCompact(opts: OpencodeCompactCallOpts & {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<OpencodeCompactResult> {
|
||||
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl}/session/${encodeURIComponent(opts.sessionId)}/summarize`;
|
||||
|
||||
try {
|
||||
const init: BunFetchInit = {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ providerID: opts.providerId, modelID: opts.modelId }),
|
||||
// Bun-specific: disables Bun's hardcoded ~5min fetch timeout.
|
||||
timeout: false,
|
||||
signal: opts.signal
|
||||
};
|
||||
const response = await fetchFn(url, init as RequestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return {
|
||||
ok: false,
|
||||
error: `OpenCode compact request failed (${response.status}): ${text.slice(0, 300)}`
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type OpencodeMessagePart = { type?: unknown; text?: unknown };
|
||||
type OpencodeMessageEntry = {
|
||||
info?: { id?: unknown; role?: unknown; parentID?: unknown; summary?: unknown };
|
||||
parts?: unknown;
|
||||
};
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an assistant message is a plausible summary carrier — without this
|
||||
* check, an unrelated adjacent/linked entry that happens to carry a `text`
|
||||
* part (e.g. another user message) could silently surface as the "summary",
|
||||
* bypassing the safe "not found -> skip" fallback this function exists to
|
||||
* provide. `info.summary === true` (observed on the real compaction
|
||||
* response) is a stronger corroborating signal when present, but role is the
|
||||
* one check we always enforce.
|
||||
*/
|
||||
function isAssistantSummaryCandidate(entry: OpencodeMessageEntry | undefined): boolean {
|
||||
return entry?.info?.role === 'assistant';
|
||||
}
|
||||
|
||||
/** Concatenates every `type:'text'` part in order — a summary can arrive as more than one text segment, and taking only the first would silently truncate it. */
|
||||
function extractTextPart(entry: OpencodeMessageEntry | undefined): string | null {
|
||||
if (!entry || !Array.isArray(entry.parts)) return null;
|
||||
const texts = (entry.parts as unknown[])
|
||||
.filter((part): part is OpencodeMessagePart => isObjectRecord(part) && part.type === 'text' && typeof part.text === 'string')
|
||||
.map((part) => part.text as string);
|
||||
return texts.length > 0 ? texts.join('') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful `triggerOpencodeCompact`, OpenCode's session history
|
||||
* contains a `{"type":"compaction"}` marker message (role `user`, no text)
|
||||
* followed by an assistant message whose `text` part holds the actual
|
||||
* summary OpenCode generated (verified 2026-07-30 via isolated E2E: parts
|
||||
* were `['step-start','reasoning','text','step-finish']`). This fetches the
|
||||
* message list and extracts that text so HAPI can show it as a "Reasoning"
|
||||
* block instead of leaving the summary invisible.
|
||||
*
|
||||
* Looks for the assistant message via its `parentID` pointing at the marker
|
||||
* first (robust to the API returning messages in an order other than
|
||||
* creation order), falling back to simple positional adjacency (the very
|
||||
* next array entry) if no `parentID` link is present. If a session has been
|
||||
* compacted more than once, only the most recent marker is considered.
|
||||
*
|
||||
* Never throws — any failure (network error, unexpected response shape, no
|
||||
* marker found, no text part found, or `signal` — see `OpencodeCompactCallOpts`
|
||||
* — firing mid-request) resolves to `{ found: false }` so the caller can
|
||||
* silently skip showing the summary rather than surfacing an error for what
|
||||
* is a purely cosmetic enhancement.
|
||||
*/
|
||||
export async function fetchCompactionSummary(opts: OpencodeCompactCallOpts & {
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<CompactionSummaryResult> {
|
||||
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl}/session/${encodeURIComponent(opts.sessionId)}/message`;
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, { method: 'GET', signal: opts.signal });
|
||||
if (!response.ok) return { found: false };
|
||||
|
||||
const data: unknown = await response.json().catch(() => null);
|
||||
if (!Array.isArray(data)) return { found: false };
|
||||
const entries = data as OpencodeMessageEntry[];
|
||||
|
||||
let markerIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const parts = entries[i]?.parts;
|
||||
if (Array.isArray(parts) && parts.some((part) => isObjectRecord(part) && part.type === 'compaction')) {
|
||||
markerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (markerIndex === -1) return { found: false };
|
||||
|
||||
const markerId = entries[markerIndex]?.info?.id;
|
||||
const byParentId = typeof markerId === 'string'
|
||||
? entries.find((entry) => entry.info?.parentID === markerId && isAssistantSummaryCandidate(entry))
|
||||
: undefined;
|
||||
|
||||
const positionalCandidate = entries[markerIndex + 1];
|
||||
const byPosition = isAssistantSummaryCandidate(positionalCandidate) ? positionalCandidate : undefined;
|
||||
|
||||
const text = extractTextPart(byParentId) ?? extractTextPart(byPosition);
|
||||
return text !== null ? { found: true, text } : { found: false };
|
||||
} catch {
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
@@ -119,15 +119,19 @@ describe('resolveOpencodeSlashCommand', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a not-yet-supported message for /clear and /compact', () => {
|
||||
it('returns a not-yet-supported message for /clear', () => {
|
||||
expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({
|
||||
kind: 'handled',
|
||||
message: '/clear is not yet supported in HAPI OpenCode sessions.'
|
||||
});
|
||||
expect(resolveOpencodeSlashCommand('/compact', state)).toEqual({
|
||||
kind: 'handled',
|
||||
message: '/compact is not yet supported in HAPI OpenCode sessions.'
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves /compact to a dedicated kind so the launcher can bridge to native compaction asynchronously', () => {
|
||||
expect(resolveOpencodeSlashCommand('/compact', state)).toEqual({ kind: 'compact' });
|
||||
});
|
||||
|
||||
it('resolves /compact the same way regardless of trailing arguments (compaction takes no arguments)', () => {
|
||||
expect(resolveOpencodeSlashCommand('/compact now please', state)).toEqual({ kind: 'compact' });
|
||||
});
|
||||
|
||||
it('expands custom OpenCode command prompts', () => {
|
||||
@@ -163,6 +167,8 @@ describe('resolveOpencodeSlashCommand', () => {
|
||||
expect(help.message).toContain('Supported OpenCode slash commands');
|
||||
expect(help.message).toContain('/plan');
|
||||
expect(help.message).toContain('/permissions');
|
||||
expect(help.message).toContain('/compact` — compact (summarize) the OpenCode session context (remote sessions only)');
|
||||
expect(help.message).toContain('/clear` is not yet supported');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ const OPENCODE_INIT_PROMPT = [
|
||||
|
||||
export type OpencodeSlashResolution =
|
||||
| { kind: 'passthrough' }
|
||||
// /compact needs an async round trip to OpenCode's internal REST API
|
||||
// (native AI compaction, can take 90s+) and a "Compaction
|
||||
// started/completed/failed" event sequence, which doesn't fit the
|
||||
// synchronous 'handled' shape below. The launcher (runOpencode.ts)
|
||||
// intercepts this kind and drives that flow itself.
|
||||
| { kind: 'compact' }
|
||||
| {
|
||||
kind: 'handled';
|
||||
message: string;
|
||||
@@ -163,7 +169,11 @@ export function resolveOpencodeSlashCommand(
|
||||
};
|
||||
}
|
||||
|
||||
if (command === 'clear' || command === 'compact') {
|
||||
if (command === 'compact') {
|
||||
return { kind: 'compact' };
|
||||
}
|
||||
|
||||
if (command === 'clear') {
|
||||
return {
|
||||
kind: 'handled',
|
||||
message: `/${command} is not yet supported in HAPI OpenCode sessions.`
|
||||
@@ -193,11 +203,12 @@ export function resolveOpencodeSlashCommand(
|
||||
'- `/plan off` — return to default permission mode',
|
||||
'- `/default` — return to default permission mode',
|
||||
'- `/init [extra]` — generate or refresh AGENTS.md for this project',
|
||||
'- `/compact` — compact (summarize) the OpenCode session context (remote sessions only)',
|
||||
'',
|
||||
'Model, reasoning effort, and permission mode have dedicated buttons in the composer. ' +
|
||||
'You can still type `/model`, `/reasoning`, or `/permissions` if you prefer.',
|
||||
'',
|
||||
'`/clear` and `/compact` are not yet supported in HAPI OpenCode sessions.',
|
||||
'`/clear` is not yet supported in HAPI OpenCode sessions.',
|
||||
'',
|
||||
'Custom commands from `~/.config/opencode/command` or `.opencode/command` are expanded before sending.'
|
||||
].join('\n')
|
||||
|
||||
Reference in New Issue
Block a user