fix(background-agent): prevent false task completion on status API outage
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { BackgroundManager } from "./manager"
|
||||||
|
import { MIN_SESSION_GONE_POLLS } from "./session-existence"
|
||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
|
type SessionStatus = { type: string }
|
||||||
|
type SessionStatusResponse = { data: Record<string, SessionStatus> }
|
||||||
|
type SessionOverrides = {
|
||||||
|
status?: (() => Promise<SessionStatusResponse>) | undefined
|
||||||
|
abort?: () => Promise<object>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRunningTask(sessionId: string): BackgroundTask {
|
||||||
|
return {
|
||||||
|
id: `bg_test_${sessionId}`,
|
||||||
|
sessionId,
|
||||||
|
parentSessionId: "parent-session",
|
||||||
|
parentMessageId: "parent-message",
|
||||||
|
description: "test task",
|
||||||
|
prompt: "test prompt",
|
||||||
|
agent: "explore",
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date(),
|
||||||
|
progress: { toolCalls: 0, lastUpdate: new Date() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createManager(overrides: SessionOverrides): BackgroundManager {
|
||||||
|
const session = {
|
||||||
|
...(overrides.status === undefined ? {} : { status: overrides.status }),
|
||||||
|
get: async () => ({ data: { id: "session" } }),
|
||||||
|
prompt: async () => ({}),
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
abort: overrides.abort ?? (async () => ({})),
|
||||||
|
todo: async () => ({ data: [] }),
|
||||||
|
messages: async () => ({
|
||||||
|
data: [{
|
||||||
|
info: { role: "assistant", finish: "end_turn", id: "message-2" },
|
||||||
|
parts: [{ type: "text", text: "done" }],
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const client = { session }
|
||||||
|
|
||||||
|
return new BackgroundManager({
|
||||||
|
pluginContext: { client, directory: tmpdir() } as PluginInput,
|
||||||
|
enableParentSessionNotifications: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(manager: BackgroundManager, cycles: number): Promise<void> {
|
||||||
|
for (let count = 0; count < cycles; count += 1) {
|
||||||
|
await manager["pollRunningTasks"]()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
|
||||||
|
manager["tasks"].set(task.id, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackgroundManager pollRunningTasks when session status registry is unavailable", () => {
|
||||||
|
test("keeps running tasks active and does not increment missed polls when status is unavailable or throws", async () => {
|
||||||
|
const cases: Array<{ name: string; status?: () => Promise<SessionStatusResponse> }> = [
|
||||||
|
{ name: "missing status method" },
|
||||||
|
{ name: "throwing status method", status: async () => { throw new Error("status unavailable") } },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
// given
|
||||||
|
let abortCallCount = 0
|
||||||
|
const manager = createManager({
|
||||||
|
status: testCase.status,
|
||||||
|
abort: async () => {
|
||||||
|
abortCallCount += 1
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const task = createRunningTask(`ses-${testCase.name.replaceAll(" ", "-")}`)
|
||||||
|
injectTask(manager, task)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await poll(manager, MIN_SESSION_GONE_POLLS + 1)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(task.status).toBe("running")
|
||||||
|
expect(task.completedAt).toBeUndefined()
|
||||||
|
expect(task.error).toBeUndefined()
|
||||||
|
expect(task.consecutiveMissedPolls ?? 0).toBe(0)
|
||||||
|
expect(abortCallCount).toBe(0)
|
||||||
|
|
||||||
|
await manager.shutdown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("completes a task when a reliable status response omits the session", async () => {
|
||||||
|
// given
|
||||||
|
const manager = createManager({
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
})
|
||||||
|
const task = createRunningTask("ses-gone-after-reliable-status")
|
||||||
|
injectTask(manager, task)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await poll(manager, MIN_SESSION_GONE_POLLS)
|
||||||
|
await manager.shutdown()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(task.status).toBe("completed")
|
||||||
|
expect(task.completedAt).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,8 +4,25 @@ import { describe, test, expect, mock } from "bun:test"
|
|||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { BackgroundManager } from "./manager"
|
import { BackgroundManager } from "./manager"
|
||||||
|
import { MIN_SESSION_GONE_POLLS } from "./session-existence"
|
||||||
import type { BackgroundTask } from "./types"
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
|
function createPluginContext(client: object): PluginInput {
|
||||||
|
const directory = tmpdir()
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: "test-project",
|
||||||
|
worktree: directory,
|
||||||
|
time: { created: Date.now() },
|
||||||
|
},
|
||||||
|
directory,
|
||||||
|
worktree: directory,
|
||||||
|
serverUrl: new URL("http://localhost:4096"),
|
||||||
|
$: {} as PluginInput["$"],
|
||||||
|
client: client as PluginInput["client"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
|
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
|
||||||
const client = {
|
const client = {
|
||||||
session: {
|
session: {
|
||||||
@@ -18,7 +35,7 @@ function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
return new BackgroundManager({ pluginContext: createPluginContext(client) })
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("BackgroundManager polling overlap", () => {
|
describe("BackgroundManager polling overlap", () => {
|
||||||
@@ -42,9 +59,9 @@ describe("BackgroundManager polling overlap", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const firstPoll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks()
|
const firstPoll = manager["pollRunningTasks"]()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
const secondPoll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks()
|
const secondPoll = manager["pollRunningTasks"]()
|
||||||
releaseStatus?.()
|
releaseStatus?.()
|
||||||
await Promise.all([firstPoll, secondPoll])
|
await Promise.all([firstPoll, secondPoll])
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
@@ -72,8 +89,7 @@ function createRunningTask(sessionId: string): BackgroundTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
|
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
|
||||||
const tasks = (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
|
manager["tasks"].set(task.id, task)
|
||||||
tasks.set(task.id, task)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
|
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
|
||||||
@@ -98,7 +114,7 @@ function createManagerWithClient(clientOverrides: Record<string, unknown> = {}):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
return new BackgroundManager(
|
return new BackgroundManager(
|
||||||
{ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: undefined, enableParentSessionNotifications: false },
|
{ pluginContext: createPluginContext(client), config: undefined, enableParentSessionNotifications: false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +167,7 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
injectTask(manager, task)
|
injectTask(manager, task)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
const poll = manager["pollRunningTasks"]
|
||||||
await poll.call(manager)
|
await poll.call(manager)
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
||||||
@@ -184,6 +200,62 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
expect(task.consecutiveMissedPolls).toBe(1)
|
expect(task.consecutiveMissedPolls).toBe(1)
|
||||||
expect(getSession).not.toHaveBeenCalled()
|
expect(getSession).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#when status polling is unavailable #then it does not complete or increment missed polls", async () => {
|
||||||
|
const cases: Array<{ name: string; status?: (() => Promise<{ data: Record<string, { type: string }> }>) | undefined }> = [
|
||||||
|
{ name: "missing status method", status: undefined },
|
||||||
|
{ name: "throwing status method", status: async () => { throw new Error("status unavailable") } },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
//#given
|
||||||
|
let abortCallCount = 0
|
||||||
|
const manager = createManagerWithClient({
|
||||||
|
status: testCase.status,
|
||||||
|
abort: async () => {
|
||||||
|
abortCallCount += 1
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const task = createRunningTask(`ses-${testCase.name.replace(/ /g, "-")}`)
|
||||||
|
injectTask(manager, task)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const poll = manager["pollRunningTasks"]
|
||||||
|
for (let count = 0; count < MIN_SESSION_GONE_POLLS + 1; count += 1) {
|
||||||
|
await poll.call(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(task.status).toBe("running")
|
||||||
|
expect(task.completedAt).toBeUndefined()
|
||||||
|
expect(task.error).toBeUndefined()
|
||||||
|
expect(task.consecutiveMissedPolls ?? 0).toBe(0)
|
||||||
|
expect(abortCallCount).toBe(0)
|
||||||
|
|
||||||
|
await manager.shutdown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#when reliable status polling omits the session #then it completes through the session-gone path", async () => {
|
||||||
|
//#given
|
||||||
|
const manager = createManagerWithClient({
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
})
|
||||||
|
const task = createRunningTask("ses-reliably-gone")
|
||||||
|
injectTask(manager, task)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const poll = manager["pollRunningTasks"]
|
||||||
|
for (let count = 0; count < MIN_SESSION_GONE_POLLS; count += 1) {
|
||||||
|
await poll.call(manager)
|
||||||
|
}
|
||||||
|
await manager.shutdown()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(task.status).toBe("completed")
|
||||||
|
expect(task.completedAt).toBeDefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("#given a running task whose session status is idle", () => {
|
describe("#given a running task whose session status is idle", () => {
|
||||||
@@ -196,7 +268,7 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
injectTask(manager, task)
|
injectTask(manager, task)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
const poll = manager["pollRunningTasks"]
|
||||||
await poll.call(manager)
|
await poll.call(manager)
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
||||||
@@ -228,7 +300,7 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
const poll = manager["pollRunningTasks"]
|
||||||
await poll.call(manager)
|
await poll.call(manager)
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
||||||
@@ -265,7 +337,7 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
const poll = manager["pollRunningTasks"]
|
||||||
await poll.call(manager)
|
await poll.call(manager)
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
||||||
@@ -285,7 +357,7 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
injectTask(manager, task)
|
injectTask(manager, task)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
const poll = manager["pollRunningTasks"]
|
||||||
await poll.call(manager)
|
await poll.call(manager)
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
||||||
@@ -304,7 +376,7 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
injectTask(manager, task)
|
injectTask(manager, task)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
const poll = manager["pollRunningTasks"]
|
||||||
await poll.call(manager)
|
await poll.call(manager)
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
||||||
@@ -322,7 +394,7 @@ describe("BackgroundManager pollRunningTasks", () => {
|
|||||||
injectTask(manager, task)
|
injectTask(manager, task)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
const poll = manager["pollRunningTasks"]
|
||||||
await poll.call(manager)
|
await poll.call(manager)
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ import {
|
|||||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||||
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
import { pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller"
|
||||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
import { checkAndInterruptStaleTasks } from "./task-poller"
|
||||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||||
import { abortWithTimeout } from "./abort-with-timeout"
|
import { abortWithTimeout } from "./abort-with-timeout"
|
||||||
@@ -214,6 +214,7 @@ export class BackgroundManager {
|
|||||||
private preStartDescendantReservations: Set<string>
|
private preStartDescendantReservations: Set<string>
|
||||||
private enableParentSessionNotifications: boolean
|
private enableParentSessionNotifications: boolean
|
||||||
private modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
private modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
|
private loggedSessionStatusUnavailable = false
|
||||||
readonly taskHistory = new TaskHistory()
|
readonly taskHistory = new TaskHistory()
|
||||||
private cachedCircuitBreakerSettings?: CircuitBreakerSettings
|
private cachedCircuitBreakerSettings?: CircuitBreakerSettings
|
||||||
|
|
||||||
@@ -2186,7 +2187,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkAndInterruptStaleTasks(
|
private async checkAndInterruptStaleTasks(
|
||||||
allStatuses: Record<string, { type: string }> = {},
|
allStatuses: SessionStatusMap | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await checkAndInterruptStaleTasks({
|
await checkAndInterruptStaleTasks({
|
||||||
tasks: this.tasks.values(),
|
tasks: this.tasks.values(),
|
||||||
@@ -2251,8 +2252,26 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
try {
|
try {
|
||||||
this.pruneStaleTasksAndNotifications()
|
this.pruneStaleTasksAndNotifications()
|
||||||
|
|
||||||
const statusResult = await this.client.session.status()
|
let allStatuses: SessionStatusMap | undefined
|
||||||
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
const sessionStatusMethod = this.client?.session?.status
|
||||||
|
if (typeof sessionStatusMethod !== "function") {
|
||||||
|
if (!this.loggedSessionStatusUnavailable) {
|
||||||
|
log("[background-agent] Unable to poll session statuses:", {
|
||||||
|
reason: "session.status unavailable",
|
||||||
|
})
|
||||||
|
this.loggedSessionStatusUnavailable = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const statusResult = await this.client.session.status()
|
||||||
|
allStatuses = normalizeSDKResponse(statusResult, {})
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.loggedSessionStatusUnavailable) {
|
||||||
|
log("[background-agent] Error polling session statuses:", { error })
|
||||||
|
this.loggedSessionStatusUnavailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.checkAndInterruptStaleTasks(allStatuses)
|
await this.checkAndInterruptStaleTasks(allStatuses)
|
||||||
|
|
||||||
@@ -2263,7 +2282,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
if (!sessionID) continue
|
if (!sessionID) continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionStatus = allStatuses[sessionID]
|
const sessionStatus = allStatuses?.[sessionID]
|
||||||
// Handle retry before checking running state
|
// Handle retry before checking running state
|
||||||
if (sessionStatus?.type === "retry") {
|
if (sessionStatus?.type === "retry") {
|
||||||
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
|
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
|
||||||
@@ -2300,8 +2319,12 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (allStatuses === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Session is idle or no longer in status response (completed/disappeared)
|
// Session is idle or no longer in status response (completed/disappeared)
|
||||||
const sessionGoneFromStatus = !sessionStatus
|
const sessionGoneFromStatus = allStatuses !== undefined && !sessionStatus
|
||||||
const sessionGoneThresholdReached = sessionGoneFromStatus
|
const sessionGoneThresholdReached = sessionGoneFromStatus
|
||||||
&& (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
|
&& (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
|
||||||
const completionSource = sessionStatus?.type === "idle"
|
const completionSource = sessionStatus?.type === "idle"
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
/// <reference types="bun-types" />
|
|
||||||
|
|
||||||
import { describe, expect, it, test } from "bun:test"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AGENT_DISPLAY_NAMES,
|
|
||||||
getAgentRuntimeName,
|
|
||||||
normalizeAgentForPromptKey,
|
|
||||||
} from "./agent-display-names"
|
|
||||||
|
|
||||||
// OpenCode Agent.list() sorts via remeda sortBy: default_agent desc, then name asc localeCompare.
|
|
||||||
// Reference: ../opencode/packages/opencode/src/agent/agent.ts:284-293.
|
|
||||||
// Earlier ZWSP prefixes silently failed: Unicode collation treats zero-width chars as ignorable.
|
|
||||||
function simulateOpencodeSort(agentNames: string[], defaultName: string): string[] {
|
|
||||||
return [...agentNames].sort((a, b) => {
|
|
||||||
const aIsDefault = a === defaultName ? 1 : 0
|
|
||||||
const bIsDefault = b === defaultName ? 1 : 0
|
|
||||||
if (aIsDefault !== bIsDefault) return bIsDefault - aIsDefault
|
|
||||||
return a.localeCompare(b)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("OpenCode Agent.list() sort with runtime-name prefixes", () => {
|
|
||||||
describe("#given the four core agents and a mix of non-core agents", () => {
|
|
||||||
test("#when sorted using opencode-style sortBy #then core agents come first in canonical order", () => {
|
|
||||||
const sisyphus = getAgentRuntimeName("sisyphus")
|
|
||||||
const hephaestus = getAgentRuntimeName("hephaestus")
|
|
||||||
const prometheus = getAgentRuntimeName("prometheus")
|
|
||||||
const atlas = getAgentRuntimeName("atlas")
|
|
||||||
|
|
||||||
const allAgents = [
|
|
||||||
sisyphus,
|
|
||||||
hephaestus,
|
|
||||||
prometheus,
|
|
||||||
atlas,
|
|
||||||
"athena",
|
|
||||||
"explore",
|
|
||||||
"metis",
|
|
||||||
"oracle",
|
|
||||||
]
|
|
||||||
|
|
||||||
const sorted = simulateOpencodeSort(allAgents, sisyphus)
|
|
||||||
const orderedConfigKeys = sorted.map((name) => normalizeAgentForPromptKey(name))
|
|
||||||
|
|
||||||
expect(orderedConfigKeys).toEqual([
|
|
||||||
"sisyphus",
|
|
||||||
"hephaestus",
|
|
||||||
"prometheus",
|
|
||||||
"atlas",
|
|
||||||
"athena",
|
|
||||||
"explore",
|
|
||||||
"metis",
|
|
||||||
"oracle",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#when default_agent is unset #then canonical core order still holds via prefix alone", () => {
|
|
||||||
const sisyphus = getAgentRuntimeName("sisyphus")
|
|
||||||
const hephaestus = getAgentRuntimeName("hephaestus")
|
|
||||||
const prometheus = getAgentRuntimeName("prometheus")
|
|
||||||
const atlas = getAgentRuntimeName("atlas")
|
|
||||||
|
|
||||||
const allAgents = [hephaestus, prometheus, atlas, sisyphus, "athena", "oracle"]
|
|
||||||
|
|
||||||
const sorted = simulateOpencodeSort(allAgents, "no-such-default-agent")
|
|
||||||
const orderedConfigKeys = sorted.map((name) => normalizeAgentForPromptKey(name))
|
|
||||||
|
|
||||||
expect(orderedConfigKeys.slice(0, 4)).toEqual([
|
|
||||||
"sisyphus",
|
|
||||||
"hephaestus",
|
|
||||||
"prometheus",
|
|
||||||
"atlas",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#given input array in random order", () => {
|
|
||||||
test("#when sorted with opencode comparator #then result is always canonical", () => {
|
|
||||||
const sisyphus = getAgentRuntimeName("sisyphus")
|
|
||||||
const hephaestus = getAgentRuntimeName("hephaestus")
|
|
||||||
const prometheus = getAgentRuntimeName("prometheus")
|
|
||||||
const atlas = getAgentRuntimeName("atlas")
|
|
||||||
const nonCore = ["athena", "explore", "librarian", "metis", "oracle"]
|
|
||||||
const allAgents = [...nonCore, atlas, prometheus, hephaestus, sisyphus]
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
||||||
const shuffled = [...allAgents]
|
|
||||||
for (let i = shuffled.length - 1; i > 0; i -= 1) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
|
||||||
}
|
|
||||||
const sorted = simulateOpencodeSort(shuffled, sisyphus)
|
|
||||||
const orderedConfigKeys = sorted.map((name) => normalizeAgentForPromptKey(name))
|
|
||||||
|
|
||||||
expect(orderedConfigKeys).toEqual([
|
|
||||||
"sisyphus",
|
|
||||||
"hephaestus",
|
|
||||||
"prometheus",
|
|
||||||
"atlas",
|
|
||||||
"athena",
|
|
||||||
"explore",
|
|
||||||
"librarian",
|
|
||||||
"metis",
|
|
||||||
"oracle",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#given runtime names containing only core agents", () => {
|
|
||||||
test("#when sorted #then sisyphus, hephaestus, prometheus, atlas in that order", () => {
|
|
||||||
const sisyphus = getAgentRuntimeName("sisyphus")
|
|
||||||
const hephaestus = getAgentRuntimeName("hephaestus")
|
|
||||||
const prometheus = getAgentRuntimeName("prometheus")
|
|
||||||
const atlas = getAgentRuntimeName("atlas")
|
|
||||||
|
|
||||||
const sorted = simulateOpencodeSort([atlas, prometheus, hephaestus, sisyphus], sisyphus)
|
|
||||||
const orderedConfigKeys = sorted.map((name) => normalizeAgentForPromptKey(name))
|
|
||||||
|
|
||||||
expect(orderedConfigKeys).toEqual([
|
|
||||||
"sisyphus",
|
|
||||||
"hephaestus",
|
|
||||||
"prometheus",
|
|
||||||
"atlas",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#given the prefix is meant to render in OpenCode TUI", () => {
|
|
||||||
it("uses ASCII whitespace so terminals render the prefix without character corruption", () => {
|
|
||||||
const runtimeNames = Object.keys(AGENT_DISPLAY_NAMES).map(getAgentRuntimeName)
|
|
||||||
const invisibleCharsRegex = /[\u200B\u200C\u200D\uFEFF]/
|
|
||||||
|
|
||||||
for (const name of runtimeNames) {
|
|
||||||
expect(invisibleCharsRegex.test(name)).toBe(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("only adds leading whitespace, never trailing or interior whitespace beyond the display name", () => {
|
|
||||||
const sisyphus = getAgentRuntimeName("sisyphus")
|
|
||||||
const hephaestus = getAgentRuntimeName("hephaestus")
|
|
||||||
const prometheus = getAgentRuntimeName("prometheus")
|
|
||||||
const atlas = getAgentRuntimeName("atlas")
|
|
||||||
|
|
||||||
for (const name of [sisyphus, hephaestus, prometheus, atlas]) {
|
|
||||||
const trimmed = name.trimStart()
|
|
||||||
expect(name.length).toBeGreaterThanOrEqual(trimmed.length)
|
|
||||||
expect(trimmed.endsWith(" ")).toBe(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user