Compare commits

...

7 Commits

Author SHA1 Message Date
github-actions[bot]
a06e656565 release: v3.1.6 2026-01-28 16:15:27 +00:00
justsisyphus
30ed086c40 fix(delegate-task): use category default model when availableModels is empty 2026-01-29 01:11:42 +09:00
justsisyphus
7c15b06da7 fix(test): update tests to reflect new model-resolver behavior 2026-01-29 00:54:16 +09:00
justsisyphus
0e7ee2ac30 chore: remove noisy console.warn for AGENTS.md auto-disable 2026-01-29 00:46:16 +09:00
justsisyphus
ca93d2f0fe fix(model-resolver): skip fallback chain when model availability cannot be verified
When model cache is empty, the fallback chain resolution was blindly
trusting connected providers without verifying if the model actually
exists. This caused errors when a provider (e.g., opencode) was marked
as connected but didn't have the requested model (e.g., claude-haiku-4-5).

Now skips fallback chain entirely when model cache is unavailable and
falls through to system default, letting OpenCode handle the resolution.
2026-01-29 00:15:57 +09:00
YeonGyu-Kim
3ab4529bc7 fix(look-at): handle JSON parse errors from session.prompt gracefully (#1216)
When multimodal-looker agent returns empty/malformed response, the SDK
throws 'JSON Parse error: Unexpected EOF'. This commit adds try-catch
around session.prompt() to provide user-friendly error message with
troubleshooting guidance.

- Add error handling for JSON parse errors with detailed guidance
- Add error handling for generic prompt failures
- Add test cases for both error scenarios
2026-01-28 23:58:01 +09:00
github-actions[bot]
9d3e152b19 @KennyDizi has signed the CLA in code-yeongyu/oh-my-opencode#1214 2026-01-28 14:26:21 +00:00
16 changed files with 193 additions and 83 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.1.5",
"version": "3.1.6",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -73,13 +73,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.1.5",
"oh-my-opencode-darwin-x64": "3.1.5",
"oh-my-opencode-linux-arm64": "3.1.5",
"oh-my-opencode-linux-arm64-musl": "3.1.5",
"oh-my-opencode-linux-x64": "3.1.5",
"oh-my-opencode-linux-x64-musl": "3.1.5",
"oh-my-opencode-windows-x64": "3.1.5"
"oh-my-opencode-darwin-arm64": "3.1.6",
"oh-my-opencode-darwin-x64": "3.1.6",
"oh-my-opencode-linux-arm64": "3.1.6",
"oh-my-opencode-linux-arm64-musl": "3.1.6",
"oh-my-opencode-linux-x64": "3.1.6",
"oh-my-opencode-linux-x64-musl": "3.1.6",
"oh-my-opencode-windows-x64": "3.1.6"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.1.5",
"version": "3.1.6",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.1.5",
"version": "3.1.6",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.1.5",
"version": "3.1.6",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.1.5",
"version": "3.1.6",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.1.5",
"version": "3.1.6",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.1.5",
"version": "3.1.6",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.1.5",
"version": "3.1.6",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -943,6 +943,14 @@
"created_at": "2026-01-28T13:04:16Z",
"repoId": 1108837393,
"pullRequestNo": 1203
},
{
"name": "KennyDizi",
"id": 16578966,
"comment_id": 3811619818,
"created_at": "2026-01-28T14:26:10Z",
"repoId": 1108837393,
"pullRequestNo": 1214
}
]
}

View File

@@ -47,18 +47,17 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
})
test("Oracle uses connected provider when no availableModels but connected cache exists", async () => {
// #given - connected providers cache exists with openai
test("Oracle falls back to system default when availableModels is empty (even with connected cache)", async () => {
// #given
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then - uses openai from connected cache
expect(agents.oracle.model).toBe("openai/gpt-5.2")
expect(agents.oracle.reasoningEffort).toBe("medium")
expect(agents.oracle.textVerbosity).toBe("high")
expect(agents.oracle.thinking).toBeUndefined()
// #then
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
expect(agents.oracle.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.oracle.reasoningEffort).toBeUndefined()
cacheSpy.mockRestore()
})
@@ -123,41 +122,39 @@ describe("createBuiltinAgents with model overrides", () => {
})
describe("createBuiltinAgents without systemDefaultModel", () => {
test("creates agents with connected provider when cache exists", async () => {
// #given - connected providers cache exists
test("agents NOT created when availableModels empty and no systemDefaultModel", async () => {
// #given
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - agents should use connected provider from fallback chain
expect(agents.oracle).toBeDefined()
expect(agents.oracle.model).toBe("openai/gpt-5.2")
// #then
expect(agents.oracle).toBeUndefined()
cacheSpy.mockRestore()
})
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
// #given - no cache and no system default
// #given
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - oracle should NOT be created (resolveModelWithFallback returns undefined)
// #then
expect(agents.oracle).toBeUndefined()
cacheSpy.mockRestore()
})
test("sisyphus uses connected provider when cache exists", async () => {
// #given - connected providers cache exists with anthropic
test("sisyphus NOT created when availableModels empty and no systemDefaultModel", async () => {
// #given
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - sisyphus should use anthropic from connected cache
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
// #then
expect(agents.sisyphus).toBeUndefined()
cacheSpy.mockRestore()
})
})

View File

@@ -144,10 +144,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION);
if (hasNativeSupport) {
console.warn(
`[oh-my-opencode] directory-agents-injector hook auto-disabled: ` +
`OpenCode ${currentVersion} has native AGENTS.md support (>= ${OPENCODE_NATIVE_AGENTS_INJECTION_VERSION})`
);
log("directory-agents-injector auto-disabled due to native OpenCode support", {
currentVersion,
nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,

View File

@@ -356,8 +356,10 @@ describe("resolveModelWithFallback", () => {
cacheSpy.mockRestore()
})
test("uses connected provider when availableModels empty but connected providers cache exists", () => {
test("skips fallback chain when availableModels empty even if connected providers cache exists", () => {
// #given - model cache missing but connected-providers cache exists
// This scenario caused bugs: provider is connected but may not have the model available
// Fix: When we can't verify model availability, skip fallback chain entirely
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
@@ -370,9 +372,32 @@ describe("resolveModelWithFallback", () => {
// #when
const result = resolveModelWithFallback(input)
// #then - should use openai (second provider) since anthropic not in connected cache
expect(result!.model).toBe("openai/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
// #then - should fall through to system default (NOT use connected provider blindly)
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("system-default")
cacheSpy.mockRestore()
})
test("prevents selecting model from provider that may not have it (bug reproduction)", () => {
// #given - user removed anthropic oauth, has quotio, but explore agent fallback has opencode
// opencode may be "connected" but doesn't have claude-haiku-4-5
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["quotio", "opencode"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
],
availableModels: new Set(), // no model cache available
systemDefaultModel: "quotio/claude-opus-4-5-20251101",
}
// #when
const result = resolveModelWithFallback(input)
// #then - should NOT return opencode/claude-haiku-4-5 (model may not exist)
// should fall through to system default which user has configured
expect(result!.model).toBe("quotio/claude-opus-4-5-20251101")
expect(result!.source).toBe("system-default")
expect(result!.model).not.toBe("opencode/claude-haiku-4-5")
cacheSpy.mockRestore()
})

View File

@@ -55,29 +55,10 @@ export function resolveModelWithFallback(
// Step 2: Provider fallback chain (with availability check)
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
// When no cache exists at all, skip fallback chain and fall through to system default
// This allows OpenCode to use Provider.defaultModel() as the final fallback
if (connectedSet === null) {
log("No cache available, skipping fallback chain to use system default")
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const model = `${provider}/${entry.model}`
log("Model resolved via fallback chain (no model cache, using connected provider)", {
provider,
model: entry.model,
variant: entry.variant,
})
return { model, source: "provider-fallback", variant: entry.variant }
}
}
}
log("No matching provider in connected cache, falling through to system default")
}
// When model cache is empty, we cannot verify if a provider actually has the model.
// Skip fallback chain entirely and fall through to system default.
// This prevents selecting provider/model combinations that may not exist.
log("No model cache available, skipping fallback chain to use system default")
}
for (const entry of fallbackChain) {

View File

@@ -537,7 +537,7 @@ To continue this session: session_id="${args.session_id}"`
}
} else {
const resolution = resolveModelWithFallback({
userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel,
userModel: userCategories?.[args.category]?.model ?? resolved.model ?? sisyphusJuniorModel,
fallbackChain: requirement.fallbackChain,
availableModels,
systemDefaultModel,
@@ -567,7 +567,7 @@ To continue this session: session_id="${args.session_id}"`
modelInfo = { model: actualModel, type, source }
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant ?? resolved.config.variant
categoryModel = parsedModel
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
: undefined

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { normalizeArgs, validateArgs } from "./tools"
import { normalizeArgs, validateArgs, createLookAt } from "./tools"
describe("look-at tool", () => {
describe("normalizeArgs", () => {
@@ -70,4 +70,80 @@ describe("look-at tool", () => {
expect(error).toContain("file_path")
})
})
describe("createLookAt error handling", () => {
// #given session.prompt에서 JSON parse 에러 발생
// #when LookAt 도구 실행
// #then 사용자 친화적 에러 메시지 반환
test("handles JSON parse error from session.prompt gracefully", async () => {
const mockClient = {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_test_json_error" } }),
prompt: async () => {
throw new Error("JSON Parse error: Unexpected EOF")
},
messages: async () => ({ data: [] }),
},
}
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
const result = await tool.execute(
{ file_path: "/test/file.png", goal: "analyze image" },
toolContext
)
expect(result).toContain("Error: Failed to analyze file")
expect(result).toContain("malformed response")
expect(result).toContain("multimodal-looker")
expect(result).toContain("image/png")
})
// #given session.prompt에서 일반 에러 발생
// #when LookAt 도구 실행
// #then 원본 에러 메시지 포함한 에러 반환
test("handles generic prompt error gracefully", async () => {
const mockClient = {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_test_generic_error" } }),
prompt: async () => {
throw new Error("Network connection failed")
},
messages: async () => ({ data: [] }),
},
}
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
const result = await tool.execute(
{ file_path: "/test/file.pdf", goal: "extract text" },
toolContext
)
expect(result).toContain("Error: Failed to send prompt")
expect(result).toContain("Network connection failed")
})
})
})

View File

@@ -131,22 +131,49 @@ Original error: ${createResult.error}`
log(`[look_at] Created session: ${sessionID}`)
log(`[look_at] Sending prompt with file passthrough to session ${sessionID}`)
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: MULTIMODAL_LOOKER_AGENT,
tools: {
task: false,
call_omo_agent: false,
look_at: false,
read: false,
try {
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: MULTIMODAL_LOOKER_AGENT,
tools: {
task: false,
call_omo_agent: false,
look_at: false,
read: false,
},
parts: [
{ type: "text", text: prompt },
{ type: "file", mime: mimeType, url: pathToFileURL(args.file_path).href, filename },
],
},
parts: [
{ type: "text", text: prompt },
{ type: "file", mime: mimeType, url: pathToFileURL(args.file_path).href, filename },
],
},
})
})
} catch (promptError) {
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
log(`[look_at] Prompt error:`, promptError)
const isJsonParseError = errorMessage.includes("JSON") && (errorMessage.includes("EOF") || errorMessage.includes("parse"))
if (isJsonParseError) {
return `Error: Failed to analyze file - received malformed response from multimodal-looker agent.
This typically occurs when:
1. The multimodal-looker model is not available or not connected
2. The model does not support this file type (${mimeType})
3. The API returned an empty or truncated response
File: ${args.file_path}
MIME type: ${mimeType}
Try:
- Ensure a vision-capable model (e.g., gemini-3-flash, gpt-5.2) is available
- Check provider connections in opencode settings
- For text files like .md, .txt, use the Read tool instead
Original error: ${errorMessage}`
}
return `Error: Failed to send prompt to multimodal-looker agent: ${errorMessage}`
}
log(`[look_at] Prompt sent, fetching messages...`)