Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ for published packages.

## [Unreleased]

### Fixed

- Reused OpenCode's host-owned OpenAI OAuth `fetch` transport for background
memory agent loops without reading, copying, or persisting OAuth tokens.
- Reported OpenCode background lifecycle failures through the host logger instead
of silently losing extraction errors.
- Removed extraction's catch-all `Failed` result so provider and Agent Loop
failures reach the host unchanged while the memory cursor remains retryable.
- Made host-resolved request options authoritative over Pi Agent Core defaults
on every background turn.
- Kept OpenCode model output limits as metadata without reintroducing a
`max_output_tokens` request field that the host OAuth hook explicitly removed.

## [0.1.1] - 2026-07-28

### Added
Expand Down
24 changes: 13 additions & 11 deletions packages/core/src/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,21 +465,23 @@ test("runExtractionSession returns Skipped when the agent writes nothing", async
}
});

test("runExtractionSession returns Failed and does NOT advance cursor when agent throws", async () => {
test("runExtractionSession exposes agent failure and does NOT advance cursor", async () => {
const root = await makeRoot();
try {
const ctx = ctxFor(root);
const cursorStore = createMemoryCursorStore();
const result = await runExtractionSession({
ctx,
agent: async () => {
throw new Error("llm down");
},
messages: [{ role: "user", text: "hi" }],
sessionId: "s2",
cursorStore,
});
assert.equal(result, ExtractionResult.Failed);
await assert.rejects(
runExtractionSession({
ctx,
agent: async () => {
throw new Error("llm down");
},
messages: [{ role: "user", text: "hi" }],
sessionId: "s2",
cursorStore,
}),
/llm down/,
);
assert.equal(cursorStore.get("s2"), null);
} finally {
await cleanup(root);
Expand Down
11 changes: 3 additions & 8 deletions packages/core/src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export const EXTRACTION_MAX_MESSAGES = 40;
export enum ExtractionResult {
Completed = "completed",
Skipped = "skipped",
Failed = "failed",
}

/**
Expand Down Expand Up @@ -442,8 +441,8 @@ export interface RunExtractionSessionOptions {
* 8 advance cursor ONLY on success; stamp .last-extraction
* 9 release the root lock
*
* A thrown agent runner (e.g. network failure) ⇒ Failed, and the cursor does
* NOT advance, so the window is retried next turn. Per-tool failures are
* A thrown agent runner (e.g. network failure) escapes unchanged, and the cursor
* does NOT advance, so the window is retried next turn. Per-tool failures are
* non-fatal (handlers return results) and simply leave files unchanged.
*/
export async function runExtractionSession(
Expand Down Expand Up @@ -473,11 +472,7 @@ export async function runExtractionSession(
const toolCtx = createMemoryFileToolContext({ ctx, refuseSecrets, sourceRef });
const tools = createFileTools();

try {
await agent({ toolCtx, tools, messages: selected, manifest, root: ctx.root });
} catch {
return ExtractionResult.Failed;
}
await agent({ toolCtx, tools, messages: selected, manifest, root: ctx.root });

await relocateRootFiles(ctx);
const after = await scanMemoryFiles(ctx.root);
Expand Down
20 changes: 18 additions & 2 deletions packages/memflywheel/src/opencode-pi-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface OpenCodePiModelConfig {
readonly input: ("text" | "image")[];
readonly contextWindow: number;
readonly maxTokens: number;
readonly requestMaxTokens?: number;
readonly fetch?: typeof globalThis.fetch;
readonly compat?: OpenAICompletionsCompat;
readonly thinkingLevelMap?: ThinkingLevelMap;
readonly temperature?: number;
Expand All @@ -30,6 +32,19 @@ export interface OpenCodePiModelConfig {

const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };

function withProviderFetch<T>(fetchImpl: typeof globalThis.fetch, run: () => T): T {
// Provider SDKs capture the default fetch while streamSimple constructs their
// client. Restore it before any asynchronous network work can interleave.
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch");
globalThis.fetch = fetchImpl;
try {
return run();
} finally {
if (descriptor) Object.defineProperty(globalThis, "fetch", descriptor);
else delete (globalThis as { fetch?: typeof globalThis.fetch }).fetch;
}
}

async function loadApi(api: Api): Promise<ProviderStreams> {
switch (api) {
case "openai-completions":
Expand Down Expand Up @@ -80,11 +95,12 @@ export function createPiAiModelBinding(config: OpenCodePiModelConfig): PiAgentMo
...(config.headers ? { headers: config.headers } : {}),
...(config.env ? { env: config.env } : {}),
...(config.temperature === undefined ? {} : { temperature: config.temperature }),
maxTokens: config.maxTokens,
...(config.requestMaxTokens === undefined ? {} : { maxTokens: config.requestMaxTokens }),
},
streamFn: async (activeModel, context, options) => {
const api = await loadApi(activeModel.api);
return api.streamSimple(activeModel, context, options);
const stream = () => api.streamSimple(activeModel, context, options);
return config.fetch ? withProviderFetch(config.fetch, stream) : stream();
},
};
}
117 changes: 117 additions & 0 deletions packages/memflywheel/src/opencode-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
hostMessagesFromOpenCodeSessionMessages,
configureOpenCodeMemoryPermission,
createOpenCodeHostModel,
createOpenCodeHarnessPort,
piApiForOpenCodeTransport,
} from "./opencode-port.js";
Expand Down Expand Up @@ -140,6 +141,34 @@ test("OpenCode buffers text-complete without blocking output, then serializes id
assert.equal(turns.length, 2);
});

test("OpenCode reports background lifecycle failures through the host logger", async () => {
const logs: unknown[] = [];
const port = createOpenCodeHarnessPort({
app: { log: async (entry) => void logs.push(entry) },
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "remember tea" }] }],
}),
},
});
port.lifecycle.onTurnEnd(async () => {
throw new Error("OAuth transport failed");
});

await assert.rejects(
port.hooks.event({ event: { type: "session.idle", properties: { sessionID: "oc-fail" } } }),
/OAuth transport failed/,
);
assert.deepEqual(logs[0], {
body: {
service: "memflywheel",
level: "error",
message: "OpenCode background lifecycle failed",
extra: { sessionId: "oc-fail", error: "Error: OAuth transport failed" },
},
});
});

test("OpenCode port fails before chat.params supplies the active model", async () => {
const port = createOpenCodeHarnessPort({});
await assert.rejects(
Expand Down Expand Up @@ -183,6 +212,94 @@ test("OpenCode resolves DeepSeek and Anthropic directly into pi-ai model binding
}
});

test("OpenCode reuses its host-owned OpenAI OAuth transport without reading tokens", () => {
const hostFetch: typeof globalThis.fetch = async () => new Response("unused");
const binding = createOpenCodeHostModel(
{
model: {
...openCodeModel("@ai-sdk/openai", "gpt-5.5", ""),
providerID: "openai",
api: { id: "gpt-5.5", npm: "@ai-sdk/openai" },
},
provider: { id: "openai", source: "custom", options: {} },
},
{ options: { apiKey: "oauth", fetch: hostFetch } },
);

assert.equal(binding.model.api, "openai-responses");
assert.equal(binding.model.baseUrl, "https://api.openai.com/v1");
assert.equal(binding.request?.fetch, undefined);
assert.equal(binding.request?.maxTokens, undefined);
assert.equal(typeof binding.request?.onPayload, "function");
assert.equal(binding.request?.access, undefined);
assert.equal(binding.request?.refresh, undefined);
});

test("OpenCode OAuth transport drives the registry pi-ai provider and omits host-owned limits", async () => {
const originalFetch = globalThis.fetch;
let requestPayload: Record<string, unknown> | undefined;
const hostFetch: typeof globalThis.fetch = async (_input, init) => {
requestPayload = JSON.parse(String(init?.body)) as Record<string, unknown>;
const sse = `data: ${JSON.stringify({
type: "response.completed",
response: {
id: "resp_memflywheel_test",
status: "completed",
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
})}\n\n`;
return new Response(sse, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
};
const binding = createOpenCodeHostModel(
{
model: {
...openCodeModel("@ai-sdk/openai", "gpt-5.5", ""),
providerID: "openai",
api: { id: "gpt-5.5", npm: "@ai-sdk/openai" },
},
provider: { id: "openai", source: "custom", options: {} },
},
{ options: { apiKey: "oauth", fetch: hostFetch } },
);

const stream = await binding.streamFn(
binding.model,
{
systemPrompt: "test",
messages: [{ role: "user", content: "test", timestamp: Date.now() }],
},
{ ...binding.request, apiKey: "oauth" },
);
assert.equal(globalThis.fetch, originalFetch);
const result = await stream.result();

assert.equal(result.stopReason, "stop");
assert.equal(requestPayload?.model, "gpt-5.5");
assert.equal(requestPayload?.max_output_tokens, undefined);
assert.equal(globalThis.fetch, originalFetch);
});

test("OpenCode rejects an OpenAI model with neither endpoint nor host transport", () => {
assert.throws(
() =>
createOpenCodeHostModel(
{
model: {
...openCodeModel("@ai-sdk/openai", "gpt-5.5", ""),
providerID: "openai",
api: { id: "gpt-5.5", npm: "@ai-sdk/openai" },
},
provider: { id: "openai", source: "custom", options: {} },
},
{ options: {} },
),
/neither a resolved endpoint nor host fetch/,
);
});

test("OpenCode transport mapping is exact and rejects unknown transports", () => {
assert.deepEqual(
[
Expand Down
Loading