Files
oh-my-opencode/src/plugin-interface.test.ts
T
YeonGyu-Kim 8b418ea38a fix(agents): remove ZWSP sort prefixes from display name helper (#3259)
AGENT_LIST_SORT_PREFIXES prepended U+200B Zero Width Space characters
to the four core agent display names so they would sort ahead of user
agents in the Tab cycle. Two problems with that approach surfaced:

  1. Some terminal emulators (Ghostty, certain Windows Terminal
     builds) render ZWSP as a visible box or extra space, producing a
     visible black gap in the status bar before "Sisyphus" and
     misaligning the layout (#3259).

  2. The prefixes leaked into the plugin API surface via config.agent
     keys, breaking prompt_async consumers that received
     ZWSP-contaminated agent names (#3238).

#3242 already removed every call site of getAgentListDisplayName() in
production code. That made the sort prefixes dead code: the constant
table was still defined but nothing read it. This PR finishes the
cleanup by:

  - Deleting the AGENT_LIST_SORT_PREFIXES constant entirely
  - Turning getAgentListDisplayName() into a thin alias over
    getAgentDisplayName() for BC with external importers
  - Keeping stripAgentListSortPrefix() as a legacy data migration for
    users upgrading from v3.14.0-v3.16.0 whose config.agent keys may
    still have ZWSP baked in from the old code path
  - Documenting the history on stripAgentListSortPrefix() so future
    maintainers understand why the stripper has to stay even after
    the injector is gone

Sort ordering is preserved via JS object insertion order in
reorderAgentsByPriority() plus the `order` field it injects on the
four core agents. Both mechanisms are already in place and both
pre-date this PR; the ZWSP prefix was an older third layer that was
only meant to work around alphabetical sorting in legacy OpenCode
before the `order` field landed upstream.

Tests: 4445 pass, 0 fail. Added 3 new assertions to
agent-display-names.test.ts verifying that getAgentListDisplayName
returns plain names containing no zero-width characters. Updated
chat-message.test.ts to use a literal ZWSP string instead of the
helper so the defensive-strip path still has coverage.

Closes #3259
2026-04-09 10:10:12 +09:00

259 lines
7.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { createPluginInterface } from "./plugin-interface"
import { createAutoSlashCommandHook } from "./hooks/auto-slash-command"
import { createStartWorkHook } from "./hooks/start-work"
import { readBoulderState } from "./features/boulder-state"
import {
_resetForTesting,
getSessionAgent,
registerAgentName,
updateSessionAgent,
} from "./features/claude-code-session-state"
describe("createPluginInterface - command.execute.before", () => {
let testDir = ""
beforeEach(() => {
testDir = join(tmpdir(), `plugin-interface-start-work-${randomUUID()}`)
mkdirSync(join(testDir, ".sisyphus", "plans"), { recursive: true })
writeFileSync(join(testDir, ".sisyphus", "plans", "worker-plan.md"), "# Plan\n- [ ] Task 1")
_resetForTesting()
registerAgentName("prometheus")
registerAgentName("sisyphus")
})
afterEach(() => {
_resetForTesting()
rmSync(testDir, { recursive: true, force: true })
})
test("executes start-work side effects for native command execution", async () => {
// given
updateSessionAgent("ses-command-before", "prometheus")
const pluginInterface = createPluginInterface({
ctx: {
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
},
managers: {} as never,
hooks: {
autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
startWork: createStartWorkHook({
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never),
} as never,
tools: {},
})
const output = {
parts: [{ type: "text", text: "original" }],
}
// when
await pluginInterface["command.execute.before"]?.(
{
command: "start-work",
sessionID: "ses-command-before",
arguments: "",
},
output as never
)
// then
expect(pluginInterface["command.execute.before"]).toBeDefined()
expect(output.parts[0]?.text).toContain("Auto-Selected Plan")
expect(output.parts[0]?.text).toContain("boulder.json has been created")
expect(getSessionAgent("ses-command-before")).toBe("sisyphus")
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
})
test("does not run start-work side effects for other native commands with session context", async () => {
// given
updateSessionAgent("ses-handoff", "prometheus")
const pluginInterface = createPluginInterface({
ctx: {
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
},
managers: {} as never,
hooks: {
autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
startWork: createStartWorkHook({
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never),
} as never,
tools: {},
})
const output = {
parts: [{ type: "text", text: "original" }],
}
// when
await pluginInterface["command.execute.before"]?.(
{
command: "handoff",
sessionID: "ses-handoff",
arguments: "",
},
output as never
)
// then
expect(output.parts[0]?.text).toContain("HANDOFF CONTEXT")
expect(readBoulderState(testDir)).toBeNull()
expect(getSessionAgent("ses-handoff")).toBe("prometheus")
})
test("switches native start-work to Atlas when Atlas is registered in config", async () => {
// given
registerAgentName("atlas")
updateSessionAgent("ses-command-atlas", "prometheus")
const pluginInterface = createPluginInterface({
ctx: {
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
},
managers: {} as never,
hooks: {
autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
startWork: createStartWorkHook({
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never),
} as never,
tools: {},
})
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "/start-work" }],
}
// when
await pluginInterface["chat.message"]?.(
{
sessionID: "ses-command-atlas",
agent: "prometheus",
} as never,
output as never
)
// then
expect(output.message.agent).toBe("atlas")
expect(getSessionAgent("ses-command-atlas")).toBe("atlas")
expect(readBoulderState(testDir)?.agent).toBe("atlas")
})
})
describe("createPluginInterface - ulw-loop native command smoke", () => {
let testDir = ""
beforeEach(() => {
testDir = join(tmpdir(), `plugin-interface-ulw-loop-${randomUUID()}`)
mkdirSync(testDir, { recursive: true })
_resetForTesting()
registerAgentName("sisyphus")
})
afterEach(() => {
_resetForTesting()
rmSync(testDir, { recursive: true, force: true })
})
test("starts the ultrawork loop from the native command flow with parsed arguments intact", async () => {
// given
const startLoopCalls: Array<{
sessionID: string
prompt: string
options: Record<string, unknown>
}> = []
const pluginInterface = createPluginInterface({
ctx: {
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
},
managers: {} as never,
hooks: {
autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
ralphLoop: {
startLoop: (sessionID: string, prompt: string, options?: Record<string, unknown>) => {
startLoopCalls.push({ sessionID, prompt, options: options ?? {} })
return true
},
cancelLoop: () => true,
getState: () => null,
},
} as never,
tools: {},
})
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "original" }],
}
// when
await pluginInterface["command.execute.before"]?.(
{
command: "ulw-loop",
sessionID: "ses-ulw-native",
arguments: '"Ship feature" --strategy=continue',
},
output as never,
)
await pluginInterface["chat.message"]?.(
{
sessionID: "ses-ulw-native",
agent: "sisyphus",
} as never,
output as never,
)
// then
expect(output.parts[0]?.text).toContain("/ulw-loop Command")
expect(startLoopCalls).toEqual([
{
sessionID: "ses-ulw-native",
prompt: "Ship feature",
options: {
ultrawork: true,
maxIterations: undefined,
completionPromise: undefined,
strategy: "continue",
},
},
])
})
})