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
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,19 @@ jobs:
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
bun test src/shared/session-utils.test.ts
bun test src/tools/skill/tools.test.ts

- name: Run remaining tests
run: |
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
# Run all other tests
# IMPORTANT: src/shared is listed as individual files to exclude
# session-utils.test.ts which uses mock.module("node:fs") and would
# pollute the fs module for all other tests in the same process.
# session-utils.test.ts is already covered in the isolated mock-heavy step above.
SHARED_TESTS=$(find src/shared -name '*.test.ts' ! -name 'session-utils.test.ts' | sort)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
src/agents src/tools $SHARED_TESTS \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
Expand Down
76 changes: 70 additions & 6 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,14 +722,78 @@ describe("GitMasterConfigSchema", () => {
}
})

test("rejects number for commit_footer", () => {
//#given
const config = { commit_footer: 123 }
test("rejects number for commit_footer", () => {
//#given
const config = { commit_footer: 123 }

//#when
const result = GitMasterConfigSchema.safeParse(config)
//#when
const result = GitMasterConfigSchema.safeParse(config)

//#then
//#then
expect(result.success).toBe(false)
})
})

describe("agent_display_names schema", () => {
test("should accept agent_display_names with string values", () => {
// given
const config = {
agent_display_names: { sisyphus: "Builder", oracle: "Debugger" },
}

// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)

// then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agent_display_names).toEqual({
sisyphus: "Builder",
oracle: "Debugger",
})
}
})

test("should reject agent_display_names with non-string values", () => {
// given
const config = {
agent_display_names: { sisyphus: 123 },
}

// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)

// then
expect(result.success).toBe(false)
})

test("should accept empty agent_display_names object", () => {
// given
const config = {
agent_display_names: {},
}

// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)

// then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agent_display_names).toEqual({})
}
})

test("should accept undefined agent_display_names (optional field)", () => {
// given
const config = {}

// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)

// then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agent_display_names).toBeUndefined()
}
})
})
6 changes: 4 additions & 2 deletions src/config/schema/oh-my-opencode-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(),
agents: AgentOverridesSchema.optional(),
disabled_tools: z.array(z.string()).optional(),
/** Map agent canonical names to custom display names (e.g., { "sisyphus": "Builder" }) */
agent_display_names: z.record(z.string(), z.string()).optional(),
agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
Expand Down
23 changes: 12 additions & 11 deletions src/hooks/todo-continuation-enforcer/idle-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { readBoulderState } from "../../features/boulder-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import type { ToolPermission } from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import { toCanonical } from "../../shared/agent-name-aliases"

import {
ABORT_WINDOW_MS,
Expand Down Expand Up @@ -112,12 +113,12 @@ export async function handleSessionIdle(args: {
path: { id: sessionID },
})
const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
hasCompactionMessage = true
continue
}
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (toCanonical(info?.agent ?? "") === "compaction") {
hasCompactionMessage = true
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resolvedInfo = {
agent: info.agent,
Expand All @@ -131,12 +132,12 @@ export async function handleSessionIdle(args: {
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) })
}

log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })

if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
return
}
if (resolvedInfo?.agent && skipAgents.includes(toCanonical(resolvedInfo.agent))) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
return
}
if (hasCompactionMessage && !resolvedInfo?.agent) {
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
return
Expand Down
185 changes: 155 additions & 30 deletions src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"

import type { BackgroundManager } from "../../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
import { initializeAgentNameAliases, resetAgentNameAliases } from "../../shared/agent-name-aliases"
import { createTodoContinuationEnforcer } from "."

type TimerCallback = (...args: any[]) => void
Expand Down Expand Up @@ -212,19 +213,21 @@ describe("todo-continuation-enforcer", () => {
} as any
}

beforeEach(() => {
fakeTimers = createFakeTimers()
_resetForTesting()
promptCalls = []
toastCalls = []
mockMessages = []
})

afterEach(() => {
fakeTimers.restore()
_resetForTesting()
cleanupBoulderFile()
})
beforeEach(() => {
fakeTimers = createFakeTimers()
_resetForTesting()
resetAgentNameAliases()
promptCalls = []
toastCalls = []
mockMessages = []
})

afterEach(() => {
fakeTimers.restore()
_resetForTesting()
resetAgentNameAliases()
cleanupBoulderFile()
})

test("should inject continuation when idle with incomplete todos", async () => {
fakeTimers.restore()
Expand Down Expand Up @@ -1415,25 +1418,147 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0)
})

test("should still inject for background task session regardless of boulder state", async () => {
fakeTimers.restore()
// given - background task session with no boulder entry
setMainSession("main-session")
const bgTaskSession = "bg-task-boulder-test"
subagentSessions.add(bgTaskSession)
cleanupBoulderFile()
test("should still inject for background task session regardless of boulder state", async () => {
fakeTimers.restore()
// given - background task session with no boulder entry
setMainSession("main-session")
const bgTaskSession = "bg-task-boulder-test"
subagentSessions.add(bgTaskSession)
cleanupBoulderFile()

const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})

// when - background task session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: bgTaskSession } },
})

await wait(2500)

// then - continuation still injected (background tasks bypass boulder check)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
}, { timeout: 15000 })

test("should canonicalize agent name when checking skipAgents list", async () => {
// given - session with renamed prometheus agent
const sessionID = "main-renamed-prometheus"
setupMainSessionWithBoulder(sessionID)

initializeAgentNameAliases(
{ prometheus: "Prometheus (Planner)" },
["prometheus", "compaction", "sisyphus"]
)

const mockMessagesWithRenamedPrometheus = [
{ info: { id: "msg-1", role: "user", agent: "Prometheus (Planner)" } },
{ info: { id: "msg-2", role: "assistant", agent: "Prometheus (Planner)" } },
]

const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesWithRenamedPrometheus }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any

const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
const hook = createTodoContinuationEnforcer(mockInput, {
skipAgents: ["prometheus"],
})

// when - background task session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: bgTaskSession } },
})
// when - session goes idle with renamed prometheus agent
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})

await wait(2500)
await fakeTimers.advanceBy(3000)

// then - continuation still injected (background tasks bypass boulder check)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
}, { timeout: 15000 })
// then - no continuation (renamed agent canonicalized and matched in skipAgents)
expect(promptCalls).toHaveLength(0)
})

test("should canonicalize compaction agent name when filtering messages", async () => {
// given - session where compaction agent is renamed
const sessionID = "main-renamed-compaction"
setupMainSessionWithBoulder(sessionID)

initializeAgentNameAliases(
{ compaction: "Compaction Agent" },
["prometheus", "compaction", "sisyphus"]
)

const mockMessagesWithRenamedCompaction = [
{ info: { id: "msg-1", role: "user", agent: "sisyphus" } },
{ info: { id: "msg-2", role: "assistant", agent: "sisyphus" } },
{ info: { id: "msg-3", role: "assistant", agent: "Compaction Agent" } },
]

const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesWithRenamedCompaction }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any

const hook = createTodoContinuationEnforcer(mockInput, {})

// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})

await fakeTimers.advanceBy(2500)

// then - continuation uses sisyphus (renamed compaction agent was filtered)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].agent).toBe("sisyphus")
})
})
Loading