Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
84229a7
feat(config): add agent_display_names schema and merge logic
potb Feb 10, 2026
da92408
feat(agents): make getAgentDisplayName config-aware with initializeAg…
potb Feb 10, 2026
6621121
feat(plugin): wire up agent_display_names initialization on plugin st…
potb Feb 10, 2026
bd4875a
feat(shared): add bidirectional agent-name-aliases module
potb Feb 10, 2026
535e861
feat(plugin-handlers): add agent-display-name-rekeyer for TUI name cu…
potb Feb 10, 2026
65817ba
feat(config): integrate agent re-keying into config pipeline and cano…
potb Feb 10, 2026
47bbf09
feat(shared): canonicalize agent names in reverse-flow comparison sites
potb Feb 10, 2026
8b520f3
fix(shared): canonicalize agent name in call-omo-agent normalization …
potb Feb 10, 2026
c685be4
test(shared): use afterEach for cleanup to prevent state leaks on ass…
potb Feb 10, 2026
c1b1001
fix(test): reset global agent alias state in afterEach and replace pl…
potb Feb 10, 2026
20fc8d9
fix(test): mock fs dependencies instead of self-module spy and add af…
potb Feb 10, 2026
9e71117
fix(review): address owner review feedback on PR #1728
potb Feb 11, 2026
8d2f46e
merge: resolve conflict with upstream/dev in look-at tools
potb Feb 11, 2026
57105ed
fix(ci): correct YAML indentation in ci.yml test step
potb Feb 11, 2026
803c1d0
fix(ci): exclude session-utils.test.ts from batch to prevent fs mock …
potb Feb 11, 2026
adffdf2
Merge remote-tracking branch 'upstream/dev' into custom-agent-names
potb Feb 12, 2026
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