fix(agent-names): use HTTP-header-safe display names and config keys for API calls (#3138)
Display names with parentheses like 'Atlas (Plan Executor)' cause HTTP
header validation errors in x-opencode-agent-name. This was blocking
Atlas/Prometheus from working via /start-work and auto-retry.
Changes:
- Display names: parens -> dashes ('Atlas - Plan Executor')
- Hooks (start-work, no-hephaestus-non-gpt, no-sisyphus-gpt): use
config keys ('atlas', 'sisyphus', 'hephaestus') for agent API fields
- auto-retry: use config key instead of display name for promptAsync
- agent-override-protection: handle dash-suffix normalization
- Updated all test expectations to match new format
Closes #3138
This commit is contained in:
@@ -1817,10 +1817,10 @@ session_id: ses_untrusted_999
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
agent: "Atlas (Plan Executor)",
|
||||
agent: "Atlas - Plan Executor",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
registerAgentName("Atlas (Plan Executor)")
|
||||
registerAgentName("Atlas - Plan Executor")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
@@ -1836,7 +1836,7 @@ session_id: ses_untrusted_999
|
||||
// then
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.agent).toBe("Atlas (Plan Executor)")
|
||||
expect(callArgs.body.agent).toBe("Atlas - Plan Executor")
|
||||
expect(callArgs.body.agent).not.toBe("atlas")
|
||||
})
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("model fallback hook", () => {
|
||||
|
||||
const set = setPendingModelFallback(
|
||||
"ses_model_fallback_main",
|
||||
"Sisyphus (Ultraworker)",
|
||||
"Sisyphus - Ultraworker",
|
||||
"anthropic",
|
||||
"claude-opus-4-6-thinking",
|
||||
)
|
||||
@@ -132,7 +132,7 @@ describe("model fallback hook", () => {
|
||||
const sessionID = "ses_model_fallback_main"
|
||||
|
||||
expect(
|
||||
setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "anthropic", "claude-opus-4-6-thinking"),
|
||||
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-6-thinking"),
|
||||
).toBe(true)
|
||||
|
||||
const firstOutput = {
|
||||
@@ -154,7 +154,7 @@ describe("model fallback hook", () => {
|
||||
|
||||
//#when - second error re-arms fallback and should advance to next entry
|
||||
expect(
|
||||
setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "anthropic", "claude-opus-4-6"),
|
||||
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-6"),
|
||||
).toBe(true)
|
||||
|
||||
const secondOutput = {
|
||||
@@ -181,13 +181,13 @@ describe("model fallback hook", () => {
|
||||
//#when
|
||||
const firstSet = setPendingModelFallback(
|
||||
sessionID,
|
||||
"Sisyphus (Ultraworker)",
|
||||
"Sisyphus - Ultraworker",
|
||||
"anthropic",
|
||||
"claude-opus-4-6-thinking",
|
||||
)
|
||||
const secondSet = setPendingModelFallback(
|
||||
sessionID,
|
||||
"Sisyphus (Ultraworker)",
|
||||
"Sisyphus - Ultraworker",
|
||||
"anthropic",
|
||||
"claude-opus-4-6-thinking",
|
||||
)
|
||||
@@ -218,7 +218,7 @@ describe("model fallback hook", () => {
|
||||
expect(
|
||||
setPendingModelFallback(
|
||||
sessionID,
|
||||
"Sisyphus (Ultraworker)",
|
||||
"Sisyphus - Ultraworker",
|
||||
"anthropic",
|
||||
"claude-opus-4-6",
|
||||
),
|
||||
@@ -262,7 +262,7 @@ describe("model fallback hook", () => {
|
||||
expect(
|
||||
setPendingModelFallback(
|
||||
sessionID,
|
||||
"Sisyphus (Ultraworker)",
|
||||
"Sisyphus - Ultraworker",
|
||||
"quotio",
|
||||
"claude-opus-4-6",
|
||||
),
|
||||
@@ -308,7 +308,7 @@ describe("model fallback hook", () => {
|
||||
expect(
|
||||
setPendingModelFallback(
|
||||
sessionID,
|
||||
"Sisyphus (Ultraworker)",
|
||||
"Sisyphus - Ultraworker",
|
||||
"provider-x",
|
||||
"current-model",
|
||||
),
|
||||
@@ -348,7 +348,7 @@ describe("model fallback hook", () => {
|
||||
|
||||
const set = setPendingModelFallback(
|
||||
"ses_model_fallback_toast",
|
||||
"Sisyphus (Ultraworker)",
|
||||
"Sisyphus - Ultraworker",
|
||||
"anthropic",
|
||||
"claude-opus-4-6-thinking",
|
||||
)
|
||||
@@ -389,7 +389,7 @@ describe("model fallback hook", () => {
|
||||
|
||||
const set = setPendingModelFallback(
|
||||
sessionID,
|
||||
"Atlas (Plan Executor)",
|
||||
"Atlas - Plan Executor",
|
||||
"github-copilot",
|
||||
"claude-sonnet-4-5",
|
||||
)
|
||||
|
||||
@@ -54,11 +54,11 @@ export function createNoHephaestusNonGptHook(
|
||||
if (allowNonGptModel) {
|
||||
return
|
||||
}
|
||||
input.agent = SISYPHUS_DISPLAY
|
||||
input.agent = "sisyphus"
|
||||
if (output?.message) {
|
||||
output.message.agent = SISYPHUS_DISPLAY
|
||||
output.message.agent = "sisyphus"
|
||||
}
|
||||
updateSessionAgent(input.sessionID, SISYPHUS_DISPLAY)
|
||||
updateSessionAgent(input.sessionID, "sisyphus")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ describe("no-hephaestus-non-gpt hook", () => {
|
||||
|
||||
// then - toast is shown and agent is switched to sisyphus
|
||||
expect(showToast).toHaveBeenCalledTimes(2)
|
||||
expect(output1.message.agent).toBe(SISYPHUS_DISPLAY)
|
||||
expect(output2.message.agent).toBe(SISYPHUS_DISPLAY)
|
||||
expect(output1.message.agent).toBe("sisyphus")
|
||||
expect(output2.message.agent).toBe("sisyphus")
|
||||
expect(showToast.mock.calls[0]?.[0]).toMatchObject({
|
||||
body: {
|
||||
title: "NEVER Use Hephaestus with Non-GPT",
|
||||
@@ -141,6 +141,6 @@ describe("no-hephaestus-non-gpt hook", () => {
|
||||
|
||||
// then - toast shown via session-agent fallback, switched to sisyphus
|
||||
expect(showToast).toHaveBeenCalledTimes(1)
|
||||
expect(output.message.agent).toBe(SISYPHUS_DISPLAY)
|
||||
expect(output.message.agent).toBe("sisyphus")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,11 +43,11 @@ export function createNoSisyphusGptHook(ctx: PluginInput) {
|
||||
|
||||
if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) {
|
||||
showToast(ctx, input.sessionID)
|
||||
input.agent = HEPHAESTUS_DISPLAY
|
||||
input.agent = "hephaestus"
|
||||
if (output?.message) {
|
||||
output.message.agent = HEPHAESTUS_DISPLAY
|
||||
output.message.agent = "hephaestus"
|
||||
}
|
||||
updateSessionAgent(input.sessionID, HEPHAESTUS_DISPLAY)
|
||||
updateSessionAgent(input.sessionID, "hephaestus")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ describe("no-sisyphus-gpt hook", () => {
|
||||
|
||||
// then - toast is shown for every message
|
||||
expect(showToast).toHaveBeenCalledTimes(2)
|
||||
expect(output1.message.agent).toBe(HEPHAESTUS_DISPLAY)
|
||||
expect(output2.message.agent).toBe(HEPHAESTUS_DISPLAY)
|
||||
expect(output1.message.agent).toBe("hephaestus")
|
||||
expect(output2.message.agent).toBe("hephaestus")
|
||||
expect(showToast.mock.calls[0]?.[0]).toMatchObject({
|
||||
body: {
|
||||
title: "NEVER Use Sisyphus with GPT",
|
||||
@@ -131,6 +131,6 @@ describe("no-sisyphus-gpt hook", () => {
|
||||
|
||||
// then - toast shown via session-agent fallback
|
||||
expect(showToast).toHaveBeenCalledTimes(1)
|
||||
expect(output.message.agent).toBe(HEPHAESTUS_DISPLAY)
|
||||
expect(output.message.agent).toBe("hephaestus")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("prometheus-md-only", () => {
|
||||
|
||||
test("should enforce md-only restriction for Prometheus display name Plan Builder", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Plan Builder)")
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus - Plan Builder")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
|
||||
@@ -133,14 +133,14 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
})
|
||||
|
||||
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
|
||||
const retryAgentDisplayName = retryAgent ? getAgentDisplayName(retryAgent) : undefined
|
||||
sessionAwaitingFallbackResult.add(sessionID)
|
||||
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
...(retryAgentDisplayName ? { agent: retryAgentDisplayName } : {}),
|
||||
// Use config key to avoid HTTP header validation issues with display names
|
||||
...(retryAgent ? { agent: retryAgent } : {}),
|
||||
...retryModelPayload,
|
||||
parts: retryParts,
|
||||
},
|
||||
|
||||
@@ -2458,7 +2458,7 @@ describe("runtime-fallback", () => {
|
||||
|
||||
expect(promptCalls.length).toBe(1)
|
||||
const callBody = promptCalls[0]?.body as Record<string, unknown>
|
||||
expect(callBody?.agent).toBe("Prometheus (Plan Builder)")
|
||||
expect(callBody?.agent).toBe("prometheus")
|
||||
expect(callBody?.model).toEqual({ providerID: "github-copilot", modelID: "claude-opus-4.6" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -453,7 +453,7 @@ You are starting a Sisyphus work session.
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
|
||||
expect(output.message.agent).toBe("atlas")
|
||||
})
|
||||
|
||||
test("should switch to Atlas even when current session is Sisyphus (regression: #3155)", async () => {
|
||||
@@ -473,7 +473,7 @@ You are starting a Sisyphus work session.
|
||||
)
|
||||
|
||||
// atlas is registered in beforeEach, so it must be selected
|
||||
expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
|
||||
expect(output.message.agent).toBe("atlas")
|
||||
expect(sessionState.getSessionAgent("ses-sisyphus-to-atlas")).toBe("atlas")
|
||||
})
|
||||
|
||||
@@ -496,7 +496,7 @@ You are starting a Sisyphus work session.
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe("Sisyphus (Ultraworker)")
|
||||
expect(output.message.agent).toBe("sisyphus")
|
||||
expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus")
|
||||
})
|
||||
|
||||
@@ -524,7 +524,7 @@ You are starting a Sisyphus work session.
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe("Sisyphus (Ultraworker)")
|
||||
expect(output.message.agent).toBe("sisyphus")
|
||||
expect(sessionState.getSessionAgent("ses-prometheus-to-worker")).toBe("sisyphus")
|
||||
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
|
||||
})
|
||||
@@ -559,7 +559,7 @@ You are starting a Sisyphus work session.
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe("Sisyphus (Ultraworker)")
|
||||
expect(output.message.agent).toBe("sisyphus")
|
||||
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
@@ -594,7 +594,7 @@ You are starting a Sisyphus work session.
|
||||
await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
|
||||
expect(output.message.agent).toBe("atlas")
|
||||
expect(readBoulderState(testDir)?.session_ids).toContain("session-123")
|
||||
expect(readBoulderState(testDir)?.agent).toBe("atlas")
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
@@ -684,7 +684,7 @@ You are starting a Sisyphus work session.
|
||||
await firePendingTimers()
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
|
||||
expect(output.message.agent).toBe("atlas")
|
||||
expect(readBoulderState(testDir)?.session_ids).toContain("session-123")
|
||||
expect(readBoulderState(testDir)?.agent).toBe("atlas")
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -85,12 +85,12 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
const activeAgent = isAgentRegistered("atlas")
|
||||
? "atlas"
|
||||
: "sisyphus"
|
||||
const activeAgentDisplayName = activeAgent === "atlas"
|
||||
? getAgentListDisplayName(activeAgent)
|
||||
: getAgentDisplayName(activeAgent)
|
||||
const activeAgentDisplayName = getAgentDisplayName(activeAgent)
|
||||
updateSessionAgent(input.sessionID, activeAgent)
|
||||
if (output.message) {
|
||||
output.message["agent"] = activeAgentDisplayName
|
||||
// Use config key for agent field to avoid HTTP header validation issues
|
||||
// Display names like "Atlas (Plan Executor)" contain parens that are invalid in headers
|
||||
output.message["agent"] = activeAgent
|
||||
}
|
||||
|
||||
const existingState = readBoulderState(ctx.directory)
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("injectContinuation", () => {
|
||||
ctx: ctx as never,
|
||||
sessionID: "ses_display_name_agent",
|
||||
resolvedInfo: {
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
agent: "Sisyphus - Ultraworker",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
},
|
||||
sessionStateStore: sessionStateStore as never,
|
||||
|
||||
Reference in New Issue
Block a user