Compare commits

...

10 Commits

Author SHA1 Message Date
YeonGyu-Kim
8b44d3b7b5 fix: add null guards for output.output in tool.execute.after hooks
Three hooks crashed with TypeError when MCP tools returned results where
output.output is undefined. Added type guards to all affected hooks:

- comment-checker/hook.ts: guard before toLowerCase()
- edit-error-recovery/hook.ts: guard before toLowerCase()
- task-resume-info/hook.ts: guard before startsWith()/includes()/trimEnd()
- Added test for undefined output.output in edit-error-recovery

Fixes #1746
2026-02-11 15:39:15 +09:00
YeonGyu-Kim
308ad1e98e Merge pull request #1683 from code-yeongyu/fix/issue-1672
fix: guard session_ids with optional chaining to prevent crash (#1672)
2026-02-11 13:33:38 +09:00
YeonGyu-Kim
d60697bb13 fix: guard session_ids with optional chaining to prevent crash
boulderState?.session_ids.includes() only guards boulderState, not
session_ids. If boulder.json is corrupted or missing the field,
session_ids is undefined and .includes() crashes silently, losing
subagent results.

Changes:
- readBoulderState: validate parsed JSON is object, default session_ids to []
- atlas hook line 427: boulderState?.session_ids?.includes
- atlas hook line 655: boulderState?.session_ids?.includes
- prometheus-md-only line 93: boulderState?.session_ids?.includes
- appendSessionId: guard with ?. and initialize to [] if missing

Fixes #1672
2026-02-11 13:27:18 +09:00
YeonGyu-Kim
95a4e971a0 test: add validation tests for readBoulderState session_ids handling
Add tests for corrupted/incomplete boulder.json:
- null JSON value returns null
- primitive JSON value returns null
- missing session_ids defaults to []
- non-array session_ids defaults to []
- empty object defaults session_ids to []
- appendSessionId with missing session_ids does not crash

Refs #1672
2026-02-11 13:25:39 +09:00
github-actions[bot]
d8901fa658 @danpung2 has signed the CLA in code-yeongyu/oh-my-opencode#1741 2026-02-11 02:52:47 +00:00
YeonGyu-Kim
82c71425a0 fix(ci): add web-flow to CLA allowlist
GitHub Web UI commits have web-flow as the author/committer,
causing CLA checks to fail even after the contributor signs.
Adding web-flow to the allowlist resolves this for all
contributors who edit files via the GitHub web interface.
2026-02-11 10:59:17 +09:00
github-actions[bot]
7e0ab828f9 release: v3.5.1 2026-02-11 01:01:58 +00:00
YeonGyu-Kim
13d960f3ca fix(look-at): revert to sync prompt to fix race condition with async polling
df0b9f76 regressed look_at from synchronous prompt (session.prompt) to
async prompt (session.promptAsync) + pollSessionUntilIdle polling. This
introduced a race condition where the poller fires before the server
registers the session as busy, causing it to return immediately with no
messages available.

Fix: restore promptSyncWithModelSuggestionRetry (blocking HTTP call) and
remove polling entirely. Catch prompt errors gracefully and still attempt
to fetch messages, since session.prompt may throw even on success.
2026-02-11 09:59:00 +09:00
github-actions[bot]
687cc2386f @marlon-costa-dc has signed the CLA in code-yeongyu/oh-my-opencode#1726 2026-02-10 18:50:08 +00:00
github-actions[bot]
d88449b1e2 @sjawhar has signed the CLA in code-yeongyu/oh-my-opencode#1727 2026-02-10 17:44:05 +00:00
24 changed files with 385 additions and 110 deletions

View File

@@ -25,7 +25,7 @@ jobs:
path-to-signatures: 'signatures/cla.json'
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
branch: 'dev'
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai,web-flow
custom-notsigned-prcomment: |
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.3.1",
"oh-my-opencode-darwin-x64": "3.3.1",
"oh-my-opencode-linux-arm64": "3.3.1",
"oh-my-opencode-linux-arm64-musl": "3.3.1",
"oh-my-opencode-linux-x64": "3.3.1",
"oh-my-opencode-linux-x64-musl": "3.3.1",
"oh-my-opencode-windows-x64": "3.3.1",
"oh-my-opencode-darwin-arm64": "3.5.1",
"oh-my-opencode-darwin-x64": "3.5.1",
"oh-my-opencode-linux-arm64": "3.5.1",
"oh-my-opencode-linux-arm64-musl": "3.5.1",
"oh-my-opencode-linux-x64": "3.5.1",
"oh-my-opencode-linux-x64-musl": "3.5.1",
"oh-my-opencode-windows-x64": "3.5.1",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-R+o42Km6bsIaW6D3I8uu2HCF3BjIWqa/fg38W5y4hJEOw4mL0Q7uV4R+0vtrXRHo9crXTK9ag0fqVQUm+Y6iAQ=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oH+c/+Z/ULIK+8T1jQFpzISHsvQPyYJfA6bceiD9sgFy1OY1NjRh4a3sFk8cXy6uRVKpivWDFOfbVTcZ2kbKWA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7VTbpR1vH3OEkoJxBKtYuxFPX8M3IbJKoeHWME9iK6FpT11W1ASsjyuhvzB1jcxSeqF8ddMnjitlG5ub6h5EVw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-wnBYQ9BZBLbzgSNIJZOIJS03zf+b4trAQeYmG+yCLn8y7FWXqw1KmjJ88/bbMXTuZ4RSMKWpXb1Afgdsred+DQ=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BZ/r/CFlvbOxkdZZrRoT16xFOjibRZHuwQnaE4f0JvOzgK6/HWp3zJI1+2/aX/oK5GA6lZxNWRrJC/SKUi8LEg=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-19KNJex1LeU/S14IsJbumOvZa9O6F7X4BLIY7MfjtHtTk0dRFL+tbbXmlafecBMigEKlLdJ+HTW3TnQgp7Ih8A=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-U90Wruf21h+CJbtcrS7MeTAc/5VOF6RI+5jr7qj/cCxjXNJtjhyJdz/maehArjtgf304+lYCM/Mh1i+G2D3YFQ=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-mCCnym3nBTJP+xzK+AS4YPFQiT2sZWmjhOhOy7PjNY6Is4jkfT1C2e9ZrIU/2VoVLV6V5q7hQGh1jgleU+FxwQ=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sYzohSNdwsAhivbXcbhPdF1qqQi2CCI7FSgbmvvfBOMyZ8HAgqOFqYW2r3GPdmtywzkjOTvCzTG56FZwEjx15w=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sDYt4adNuwb+p1RzHb7IR9zvbAnYYgZofjPvceirBorffp63f+aypYFxjFpfmbT87o/Eb/Hgzm4sHliJtd1UmQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aG5pZ4eWS0YSGUicOnjMkUPrIqQV4poYF+d9SIvrfvlaMcK6WlQn7jXzgNCwJsfGn5lyhSmjshZBEU+v79Ua3w=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-tz/0QSS5AKIiKj6cMom5VQSnEYpMIP/SRTaP5WYNOYhnUkXMwXEncQ7FIcj2vovMCXuqA9a8ujVY0zTs7TeALw=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-FGH7cnzBqNwjSkzCDglMsVttaq+MsykAxa7ehaFK+0dnBZArvllS3W13a3dGaANHMZzfK0vz8hNDUdVi7Z63cA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-zfpRS6HIkSwE8btajJzSYxhqsE5kDkop896/XGS3LLIAAZt0RtCmT3C1plxVfI9oAABfgcaiveCxJ5f9AlKPcQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.5.0",
"version": "3.5.1",
"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",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.0",
"oh-my-opencode-darwin-x64": "3.5.0",
"oh-my-opencode-linux-arm64": "3.5.0",
"oh-my-opencode-linux-arm64-musl": "3.5.0",
"oh-my-opencode-linux-x64": "3.5.0",
"oh-my-opencode-linux-x64-musl": "3.5.0",
"oh-my-opencode-windows-x64": "3.5.0"
"oh-my-opencode-darwin-arm64": "3.5.1",
"oh-my-opencode-darwin-x64": "3.5.1",
"oh-my-opencode-linux-arm64": "3.5.1",
"oh-my-opencode-linux-arm64-musl": "3.5.1",
"oh-my-opencode-linux-x64": "3.5.1",
"oh-my-opencode-linux-x64-musl": "3.5.1",
"oh-my-opencode-windows-x64": "3.5.1"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.5.0",
"version": "3.5.1",
"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.5.0",
"version": "3.5.1",
"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.5.0",
"version": "3.5.1",
"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.5.0",
"version": "3.5.1",
"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.5.0",
"version": "3.5.1",
"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.5.0",
"version": "3.5.1",
"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.5.0",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1319,6 +1319,38 @@
"created_at": "2026-02-10T15:32:31Z",
"repoId": 1108837393,
"pullRequestNo": 1723
},
{
"name": "sjawhar",
"id": 5074378,
"comment_id": 3879746658,
"created_at": "2026-02-10T17:43:47Z",
"repoId": 1108837393,
"pullRequestNo": 1727
},
{
"name": "marlon-costa-dc",
"id": 128386606,
"comment_id": 3879827362,
"created_at": "2026-02-10T17:59:06Z",
"repoId": 1108837393,
"pullRequestNo": 1726
},
{
"name": "marlon-costa-dc",
"id": 128386606,
"comment_id": 3879847814,
"created_at": "2026-02-10T18:03:41Z",
"repoId": 1108837393,
"pullRequestNo": 1726
},
{
"name": "danpung2",
"id": 75434746,
"comment_id": 3881834946,
"created_at": "2026-02-11T02:52:34Z",
"repoId": 1108837393,
"pullRequestNo": 1741
}
]
}

View File

@@ -43,6 +43,78 @@ describe("boulder-state", () => {
expect(result).toBeNull()
})
test("should return null for JSON null value", () => {
//#given - boulder.json containing null
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, "null")
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).toBeNull()
})
test("should return null for JSON primitive value", () => {
//#given - boulder.json containing a string
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, '"just a string"')
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).toBeNull()
})
test("should default session_ids to [] when missing from JSON", () => {
//#given - boulder.json without session_ids field
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
plan_name: "plan",
}))
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).not.toBeNull()
expect(result!.session_ids).toEqual([])
})
test("should default session_ids to [] when not an array", () => {
//#given - boulder.json with session_ids as a string
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
session_ids: "not-an-array",
plan_name: "plan",
}))
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).not.toBeNull()
expect(result!.session_ids).toEqual([])
})
test("should default session_ids to [] for empty object", () => {
//#given - boulder.json with empty object
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({}))
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).not.toBeNull()
expect(result!.session_ids).toEqual([])
})
test("should read valid boulder state", () => {
// given - valid boulder.json
const state: BoulderState = {
@@ -129,6 +201,23 @@ describe("boulder-state", () => {
// then
expect(result).toBeNull()
})
test("should not crash when boulder.json has no session_ids field", () => {
//#given - boulder.json without session_ids
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/plan.md",
started_at: "2026-01-01T00:00:00Z",
plan_name: "plan",
}))
//#when
const result = appendSessionId(TEST_DIR, "ses-new")
//#then - should not crash and should contain the new session
expect(result).not.toBeNull()
expect(result!.session_ids).toContain("ses-new")
})
})
describe("clearBoulderState", () => {

View File

@@ -22,7 +22,14 @@ export function readBoulderState(directory: string): BoulderState | null {
try {
const content = readFileSync(filePath, "utf-8")
return JSON.parse(content) as BoulderState
const parsed = JSON.parse(content)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null
}
if (!Array.isArray(parsed.session_ids)) {
parsed.session_ids = []
}
return parsed as BoulderState
} catch {
return null
}
@@ -48,7 +55,10 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
const state = readBoulderState(directory)
if (!state) return null
if (!state.session_ids.includes(sessionId)) {
if (!state.session_ids?.includes(sessionId)) {
if (!Array.isArray(state.session_ids)) {
state.session_ids = []
}
state.session_ids.push(sessionId)
if (writeBoulderState(directory, state)) {
return state

View File

@@ -41,7 +41,7 @@ export function createAtlasEventHandler(input: {
// Read boulder state FIRST to check if this session is part of an active boulder
const boulderState = readBoulderState(ctx.directory)
const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false
const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false
const isBackgroundTaskSession = subagentSessions.has(sessionID)

View File

@@ -65,7 +65,7 @@ export function createToolExecuteAfterHandler(input: {
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
if (toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)) {
if (toolInput.sessionID && !boulderState.session_ids?.includes(toolInput.sessionID)) {
appendSessionId(ctx.directory, toolInput.sessionID)
log(`[${HOOK_NAME}] Appended session to boulder`, {
sessionID: toolInput.sessionID,

View File

@@ -0,0 +1,24 @@
import { describe, it, expect, mock } from "bun:test"
mock.module("./cli-runner", () => ({
initializeCommentCheckerCli: () => {},
getCommentCheckerCliPathPromise: () => Promise.resolve("/tmp/fake-comment-checker"),
isCliPathUsable: () => true,
processWithCli: async () => {},
processApplyPatchEditsWithCli: async () => {},
}))
const { createCommentCheckerHooks } = await import("./hook")
describe("comment-checker output guard", () => {
//#given output.output is undefined
//#when tool.execute.after is called
//#then should return without throwing
it("should not throw when output.output is undefined", async () => {
const hooks = createCommentCheckerHooks()
const input = { tool: "Write", sessionID: "ses_test", callID: "call_test" }
const output = { title: "ok", output: undefined as unknown as string, metadata: {} }
await expect(hooks["tool.execute.after"](input, output)).resolves.toBeUndefined()
})
})

View File

@@ -89,6 +89,8 @@ export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
): Promise<void> => {
debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID })
if (!output.output || typeof output.output !== "string") return
const toolLower = input.tool.toLowerCase()
// Only skip if the output indicates a tool execution failure

View File

@@ -43,6 +43,7 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) {
output: { title: string; output: string; metadata: unknown }
) => {
if (input.tool.toLowerCase() !== "edit") return
if (!output.output || typeof output.output !== "string") return
const outputLower = output.output.toLowerCase()
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>

View File

@@ -21,6 +21,17 @@ describe("createEditErrorRecoveryHook", () => {
metadata: {},
})
describe("#given output.output is undefined", () => {
//#when tool.execute.after is called
//#then should return without throwing
it("#then should not throw", async () => {
const input = createInput("Edit")
const output = { title: "Edit", output: undefined as unknown as string, metadata: {} }
await expect(hook["tool.execute.after"](input, output)).resolves.toBeUndefined()
})
})
describe("#given Edit tool with oldString/newString same error", () => {
describe("#when the error message is detected", () => {
it("#then should append the recovery reminder", async () => {

View File

@@ -43,7 +43,7 @@ export function getAgentFromSession(sessionID: string, directory: string): strin
// Check boulder state (persisted across restarts) - fixes #927
const boulderState = readBoulderState(directory)
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
if (boulderState?.session_ids?.includes(sessionID) && boulderState.agent) {
return boulderState.agent
}

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from "bun:test"
import { createTaskResumeInfoHook } from "./hook"
describe("createTaskResumeInfoHook", () => {
describe("tool.execute.after", () => {
//#given output.output is undefined
//#when tool.execute.after is called
//#then should return without throwing
it("should not throw when output.output is undefined", async () => {
const hook = createTaskResumeInfoHook()
const input = { tool: "Task", sessionID: "ses_test", callID: "call_test" }
const output = { title: "Result", output: undefined as unknown as string, metadata: {} }
await expect(hook["tool.execute.after"](input, output)).resolves.toBeUndefined()
})
})
})

View File

@@ -21,6 +21,7 @@ export function createTaskResumeInfoHook() {
output: { title: string; output: string; metadata: unknown }
) => {
if (!TARGET_TOOLS.includes(input.tool)) return
if (!output.output || typeof output.output !== "string") return
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
if (output.output.includes("\nto continue:")) return

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, test, mock } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { normalizeArgs, validateArgs, createLookAt } from "./tools"
@@ -111,16 +111,15 @@ describe("look-at tool", () => {
})
describe("createLookAt error handling", () => {
// given promptAsync throws error
// given sync prompt throws and no messages available
// when LookAt tool executed
// then returns error string immediately (no message fetch)
test("returns error immediately when promptAsync fails", async () => {
// then returns no-response error (fetches messages after catching prompt error)
test("returns no-response error when prompt fails and no messages exist", async () => {
const mockClient = {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_test_prompt_fail" } }),
promptAsync: async () => { throw new Error("Network connection failed") },
status: async () => ({ data: {} }),
prompt: async () => { throw new Error("Network connection failed") },
messages: async () => ({ data: [] }),
},
}
@@ -146,51 +145,10 @@ describe("look-at tool", () => {
toolContext,
)
expect(result).toContain("Error")
expect(result).toContain("Network connection failed")
expect(result).toContain("multimodal-looker")
})
// given promptAsync succeeds but status API fails (polling degrades gracefully)
// when LookAt tool executed
// then still attempts to fetch messages (graceful degradation)
test("fetches messages even when status API fails", async () => {
const mockClient = {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_test_poll_timeout" } }),
promptAsync: async () => ({}),
status: async () => ({ error: new Error("status unavailable") }),
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "partial result" }] },
],
}),
},
}
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
const toolContext: ToolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
directory: "/project",
worktree: "/project",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
}
const result = await tool.execute(
{ file_path: "/test/file.png", goal: "analyze" },
toolContext,
)
expect(result).toBe("partial result")
})
// given promptAsync succeeds and session becomes idle
// given sync prompt succeeds
// when LookAt tool executed and no assistant message found
// then returns error about no response
test("returns error when no assistant message after successful prompt", async () => {
@@ -198,8 +156,7 @@ describe("look-at tool", () => {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_test_no_msg" } }),
promptAsync: async () => ({}),
status: async () => ({ data: {} }),
prompt: async () => ({}),
messages: async () => ({ data: [] }),
},
}
@@ -236,8 +193,7 @@ describe("look-at tool", () => {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ error: "Internal server error" }),
promptAsync: async () => ({}),
status: async () => ({ data: {} }),
prompt: async () => ({}),
messages: async () => ({ data: [] }),
},
}
@@ -270,8 +226,8 @@ describe("look-at tool", () => {
describe("createLookAt model passthrough", () => {
// given multimodal-looker agent has resolved model info
// when LookAt tool executed
// then model info should be passed to promptAsync
test("passes multimodal-looker model to promptAsync when available", async () => {
// then model info should be passed to sync prompt
test("passes multimodal-looker model to sync prompt when available", async () => {
let promptBody: any
const mockClient = {
@@ -289,11 +245,10 @@ describe("look-at tool", () => {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_model_passthrough" } }),
promptAsync: async (input: any) => {
prompt: async (input: any) => {
promptBody = input.body
return { data: {} }
},
status: async () => ({ data: {} }),
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "done" }] },
@@ -330,10 +285,154 @@ describe("look-at tool", () => {
})
})
describe("createLookAt sync prompt (race condition fix)", () => {
// given look_at needs response immediately after prompt returns
// when tool is executed
// then must use synchronous prompt (session.prompt), NOT async (session.promptAsync)
test("uses synchronous prompt to avoid race condition with polling", async () => {
const syncPrompt = mock(async () => ({}))
const asyncPrompt = mock(async () => ({}))
const statusFn = mock(async () => ({ data: {} }))
const mockClient = {
app: {
agents: async () => ({ data: [] }),
},
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_sync_test" } }),
prompt: syncPrompt,
promptAsync: asyncPrompt,
status: statusFn,
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "result" }] },
],
}),
},
}
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
const toolContext: ToolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
directory: "/project",
worktree: "/project",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
}
const result = await tool.execute(
{ file_path: "/test/file.png", goal: "analyze" },
toolContext,
)
expect(result).toBe("result")
expect(syncPrompt).toHaveBeenCalledTimes(1)
expect(asyncPrompt).not.toHaveBeenCalled()
expect(statusFn).not.toHaveBeenCalled()
})
// given sync prompt throws (JSON parse error even on success)
// when tool is executed
// then catches error gracefully and still fetches messages
test("catches sync prompt errors and still fetches messages", async () => {
const mockClient = {
app: {
agents: async () => ({ data: [] }),
},
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_sync_error" } }),
prompt: async () => { throw new Error("JSON parse error") },
promptAsync: async () => ({}),
status: async () => ({ data: {} }),
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "result despite error" }] },
],
}),
},
}
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
const toolContext: ToolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
directory: "/project",
worktree: "/project",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
}
const result = await tool.execute(
{ file_path: "/test/file.png", goal: "analyze" },
toolContext,
)
expect(result).toBe("result despite error")
})
// given sync prompt throws and no messages available
// when tool is executed
// then returns error about no response
test("returns no-response error when sync prompt fails and no messages", async () => {
const mockClient = {
app: {
agents: async () => ({ data: [] }),
},
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_sync_no_msg" } }),
prompt: async () => { throw new Error("Connection refused") },
promptAsync: async () => ({}),
status: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
const toolContext: ToolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
directory: "/project",
worktree: "/project",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
}
const result = await tool.execute(
{ file_path: "/test/file.png", goal: "analyze" },
toolContext,
)
expect(result).toContain("Error")
expect(result).toContain("multimodal-looker")
})
})
describe("createLookAt with image_data", () => {
// given base64 image data is provided
// when LookAt tool executed
// then should send data URL to promptAsync
// then should send data URL to sync prompt
test("sends data URL when image_data provided", async () => {
let promptBody: any
@@ -344,11 +443,10 @@ describe("look-at tool", () => {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_image_data_test" } }),
promptAsync: async (input: any) => {
prompt: async (input: any) => {
promptBody = input.body
return { data: {} }
},
status: async () => ({ data: {} }),
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },
@@ -398,11 +496,10 @@ describe("look-at tool", () => {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_raw_base64_test" } }),
promptAsync: async (input: any) => {
prompt: async (input: any) => {
promptBody = input.body
return { data: {} }
},
status: async () => ({ data: {} }),
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },

View File

@@ -3,8 +3,7 @@ import { pathToFileURL } from "node:url"
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
import type { LookAtArgs } from "./types"
import { log, promptWithModelSuggestionRetry } from "../../shared"
import { pollSessionUntilIdle } from "./session-poller"
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
import { extractLatestAssistantText } from "./assistant-message-extractor"
import type { LookAtArgsWithAlias } from "./look-at-arguments"
import { normalizeArgs, validateArgs } from "./look-at-arguments"
@@ -106,9 +105,9 @@ Original error: ${createResult.error}`
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
log(`[look_at] Sending async prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
try {
await promptWithModelSuggestionRetry(ctx.client, {
await promptSyncWithModelSuggestionRetry(ctx.client, {
path: { id: sessionID },
body: {
agent: MULTIMODAL_LOOKER_AGENT,
@@ -127,15 +126,7 @@ Original error: ${createResult.error}`
},
})
} catch (promptError) {
log(`[look_at] promptAsync error:`, promptError)
return `Error: Failed to send prompt to multimodal-looker agent: ${promptError instanceof Error ? promptError.message : String(promptError)}`
}
log(`[look_at] Polling session ${sessionID} until idle...`)
try {
await pollSessionUntilIdle(ctx.client, sessionID, { pollIntervalMs: 500, timeoutMs: 120_000 })
} catch (pollError) {
log(`[look_at] Polling error (will still try to fetch messages):`, pollError)
log(`[look_at] Prompt error (ignored, will still fetch messages):`, promptError)
}
log(`[look_at] Fetching messages from session ${sessionID}...`)