Skip to content
12 changes: 12 additions & 0 deletions packages/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ When the env var is set, workflow hooks are skipped and tools throw a clear erro

**When unset**, workflows are active for all agents (default behavior).

### Auto-Compaction

When transitioning to a new phase via `proceed_to_phase`, the plugin automatically triggers a session compaction (summarize) to clear prior-phase context from the LLM window. This is enabled by default.

Set `WORKFLOW_AUTO_COMPACT=false` to disable this behavior:

```bash
WORKFLOW_AUTO_COMPACT=false npx opencode
```

**When unset or any value other than `false`**, compaction runs on every successful phase transition (default behavior).

### Per-Agent Behavior

- **Agent in filter**: Workflow instructions are injected on every message, tools work normally
Expand Down
35 changes: 23 additions & 12 deletions packages/opencode-plugin/src/tool-handlers/proceed-to-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,33 @@ export function createProceedToPhaseTool(
});

// Trigger compaction to clear prior-phase context from the LLM window.
// Skipped when WORKFLOW_AUTO_COMPACT=false; default is enabled.
// Fire-and-forget: a failed compaction must never block the phase transition.
// The summarize API requires providerID + modelID; we use the last-known
// model from the chat.message hook (cached in the plugin closure).
const model = getModel();
client.session
.summarize({
path: { id: context.sessionID },
...(model ? { body: model } : {}),
})
.catch(() => {});
const autoCompact = process.env['WORKFLOW_AUTO_COMPACT']
?.trim()
?.toLowerCase();
if (autoCompact !== 'false') {
const model = getModel();
client.session
.summarize({
path: { id: context.sessionID },
...(model ? { body: model } : {}),
})
.catch(() => {});

logger.info('Triggered compaction after phase transition', {
phase: data.phase,
sessionID: context.sessionID,
hasModel: !!model,
});
logger.info('Triggered compaction after phase transition', {
phase: data.phase,
sessionID: context.sessionID,
hasModel: !!model,
});
} else {
logger.debug('Skipped compaction: WORKFLOW_AUTO_COMPACT=false', {
phase: data.phase,
Comment thread
udondan marked this conversation as resolved.
sessionID: context.sessionID,
});
}

// Build response with instructions (strip whats_next references)
const lines: string[] = [];
Expand Down
58 changes: 56 additions & 2 deletions packages/opencode-plugin/test/e2e/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,26 @@ import { tmpdir } from 'node:os';
import { WorkflowsPlugin } from '../../src/plugin.js';
import type { PluginInput, Hooks, Part, UserMessage } from '../../src/types.js';

// Ensure WORKFLOW_AGENTS is unset for the baseline test suite.
// Tests that need it set/restored manage it themselves in try/finally blocks.
// Ensure WORKFLOW_AGENTS and WORKFLOW_AUTO_COMPACT are unset for the baseline
// test suite. Per-test overrides can set them as needed, and shared cleanup below
// restores the original process environment after each test.
const _savedWorkflowAgents = process.env.WORKFLOW_AGENTS;
const _savedWorkflowAutoCompact = process.env.WORKFLOW_AUTO_COMPACT;
beforeEach(() => {
delete process.env.WORKFLOW_AGENTS;
delete process.env.WORKFLOW_AUTO_COMPACT;
});
afterEach(() => {
if (_savedWorkflowAgents === undefined) {
delete process.env.WORKFLOW_AGENTS;
} else {
process.env.WORKFLOW_AGENTS = _savedWorkflowAgents;
}
if (_savedWorkflowAutoCompact === undefined) {
delete process.env.WORKFLOW_AUTO_COMPACT;
} else {
process.env.WORKFLOW_AUTO_COMPACT = _savedWorkflowAutoCompact;
}
});

// Test utilities
Expand Down Expand Up @@ -668,6 +676,52 @@ describe('OpenCode Workflows Plugin E2E', () => {
expect(summarizeMock).not.toHaveBeenCalled();
});

it('does not trigger compaction when WORKFLOW_AUTO_COMPACT=false', async () => {
process.env['WORKFLOW_AUTO_COMPACT'] = 'false';

await setupWorkflowState(testDir, {
workflowName: 'epcc',
currentPhase: 'explore',
});

hooks = await WorkflowsPlugin(mockInput);

await hooks['chat.message']!(
{
sessionID: 'test-session-789',
model: {
providerID: 'github-copilot',
modelID: 'claude-sonnet-4.6',
},
},
{
message: {
id: 'msg-1',
sessionID: 'test-session-789',
role: 'user',
},
parts: [],
}
);

const sessionID = 'test-session-789';
const proceedResult = await hooks.tool!.proceed_to_phase.execute(
{ target_phase: 'plan', reason: 'exploration complete' },
{ sessionID } as never
);

// Verify the transition itself succeeded before checking compaction was skipped
expect(JSON.stringify(proceedResult)).toContain('plan');

// session.summarize should NOT have been called
const summarizeMock = (
mockInput.client as {
session: { summarize: ReturnType<typeof vi.fn> };
}
).session.summarize;
expect(summarizeMock).not.toHaveBeenCalled();
});

it('fails when no workflow is active', async () => {
hooks = await WorkflowsPlugin(mockInput);

Expand Down
Loading