Merge pull request #4465 from code-yeongyu/fix-4417-atlas-path-and-start-work
fix(atlas): repair task() path crash and start-work agent resolution (#4417)
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
import {
|
||||||
|
_resetForTesting,
|
||||||
|
registerAgentName,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
|
||||||
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
|
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||||
|
|
||||||
|
describe("ralph-loop continuation prompt agent resolution", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
_resetForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given OpenCode registered Atlas under legacy display name #when inherited agent is config key #then prompt uses registered name", async () => {
|
||||||
|
// given
|
||||||
|
registerAgentName("Atlas (Plan Executor)")
|
||||||
|
let capturedAgent: string | undefined
|
||||||
|
const ctx = unsafeTestValue<PluginInput>({
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: async () => ({ data: [{ info: { agent: "atlas" } }] }),
|
||||||
|
promptAsync: async (input: { readonly body: { readonly agent?: string } }) => {
|
||||||
|
capturedAgent = input.body.agent
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await injectContinuationPrompt(ctx, {
|
||||||
|
sessionID: "ses_ralph_registered_atlas",
|
||||||
|
prompt: "continue",
|
||||||
|
directory: "/tmp/test",
|
||||||
|
apiTimeoutMs: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(capturedAgent).toBe("Atlas (Plan Executor)")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
normalizeSDKResponse,
|
normalizeSDKResponse,
|
||||||
resolveInheritedPromptTools,
|
resolveInheritedPromptTools,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { normalizeAgentForPrompt, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
|
||||||
|
import { normalizeAgentForPromptKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||||
|
|
||||||
type MessageInfo = {
|
type MessageInfo = {
|
||||||
@@ -62,20 +63,10 @@ function createPromptAsyncError(prefix: string, error: unknown): Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInheritedAgentForPrompt(agent: string | undefined): string | undefined {
|
function normalizeInheritedAgentForPrompt(agent: string | undefined): string | undefined {
|
||||||
if (typeof agent !== "string") {
|
const resolvedAgent = resolveRegisteredAgentName(agent) ?? normalizeAgentForPromptKey(agent)
|
||||||
return undefined
|
if (typeof resolvedAgent !== "string") return undefined
|
||||||
}
|
const cleanAgent = stripAgentListSortPrefix(resolvedAgent).trim()
|
||||||
|
return cleanAgent || undefined
|
||||||
const inheritedAgent = stripAgentListSortPrefix(agent).trim()
|
|
||||||
if (!inheritedAgent) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inheritedAgent.includes(" - ")) {
|
|
||||||
return inheritedAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeAgentForPrompt(inheritedAgent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectContinuationPrompt(
|
export async function injectContinuationPrompt(
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
import {
|
||||||
|
_resetForTesting,
|
||||||
|
registerAgentName,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
|
||||||
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
|
import { injectContinuation } from "./continuation-injection"
|
||||||
|
|
||||||
|
describe("todo continuation registered agent resolution", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
_resetForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given OpenCode registered Atlas under legacy display name #when continuation inherits config key #then prompt uses registered name", async () => {
|
||||||
|
// given
|
||||||
|
registerAgentName("Atlas (Plan Executor)")
|
||||||
|
let capturedAgent: string | undefined
|
||||||
|
const ctx = unsafeTestValue<PluginInput>({
|
||||||
|
directory: "/tmp/test",
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
todo: async () => ({ data: [{ id: "1", content: "todo", status: "pending", priority: "high" }] }),
|
||||||
|
promptAsync: async (input: { readonly body: { readonly agent?: string } }) => {
|
||||||
|
capturedAgent = input.body.agent
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const sessionStateStore = {
|
||||||
|
getExistingState: () => ({ inFlight: false, lastInjectedAt: 0, consecutiveFailures: 0 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await injectContinuation({
|
||||||
|
ctx,
|
||||||
|
sessionID: "ses_todo_registered_atlas",
|
||||||
|
resolvedInfo: {
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5.5" },
|
||||||
|
},
|
||||||
|
sessionStateStore: unsafeTestValue(sessionStateStore),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(capturedAgent).toBe("Atlas (Plan Executor)")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,8 +20,8 @@ import { log } from "../../shared/logger"
|
|||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||||
import {
|
import {
|
||||||
getAgentConfigKey,
|
getAgentConfigKey,
|
||||||
normalizeAgentForPrompt,
|
|
||||||
normalizeAgentForPromptKey,
|
normalizeAgentForPromptKey,
|
||||||
|
stripAgentListSortPrefix,
|
||||||
} from "../../shared/agent-display-names"
|
} from "../../shared/agent-display-names"
|
||||||
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
|
||||||
|
|
||||||
@@ -132,9 +132,8 @@ export async function injectContinuation(args: {
|
|||||||
tools = tools ?? previousMessage?.tools
|
tools = tools ?? previousMessage?.tools
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptAgent = normalizeAgentForPromptKey(agentName)
|
const promptAgent = resolveRegisteredAgentName(agentName) ?? normalizeAgentForPromptKey(agentName)
|
||||||
const resolvedAgent = resolveRegisteredAgentName(agentName)
|
const launchAgent = promptAgent ? stripAgentListSortPrefix(promptAgent).trim() || undefined : undefined
|
||||||
const launchAgent = normalizeAgentForPrompt(resolvedAgent ?? agentName)
|
|
||||||
|
|
||||||
if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) {
|
if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) {
|
||||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
import {
|
||||||
|
dispatchInternalPrompt,
|
||||||
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
|
} from "./prompt-async-gate"
|
||||||
|
|
||||||
|
type CompatPromptInput = {
|
||||||
|
readonly path: { readonly id: string } | string
|
||||||
|
readonly body: {
|
||||||
|
readonly parts: readonly []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPathSensitivePrompt() {
|
||||||
|
const calls: CompatPromptInput[] = []
|
||||||
|
const prompt = mock(async (input: CompatPromptInput) => {
|
||||||
|
calls.push(input)
|
||||||
|
if (typeof input.path !== "string") {
|
||||||
|
throw new TypeError('The "path" property must be of type string, got object')
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { calls, prompt }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dispatchInternalPrompt path compatibility", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given sync prompt rejects object-form session path #when dispatching #then it retries with string-form path", async () => {
|
||||||
|
// given
|
||||||
|
const { calls, prompt } = createPathSensitivePrompt()
|
||||||
|
const client = { session: { prompt } }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await dispatchInternalPrompt<CompatPromptInput>({
|
||||||
|
mode: "sync",
|
||||||
|
client,
|
||||||
|
sessionID: "ses_sync_path_compat",
|
||||||
|
source: "test:path-compat:sync",
|
||||||
|
settleMs: 0,
|
||||||
|
checkStatus: false,
|
||||||
|
checkToolState: false,
|
||||||
|
queueBehavior: "defer",
|
||||||
|
input: {
|
||||||
|
path: { id: "ses_sync_path_compat" },
|
||||||
|
body: { parts: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("dispatched")
|
||||||
|
expect(calls.map((call) => call.path)).toEqual([
|
||||||
|
{ id: "ses_sync_path_compat" },
|
||||||
|
"ses_sync_path_compat",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given async prompt rejects object-form session path #when dispatching #then it retries with string-form path", async () => {
|
||||||
|
// given
|
||||||
|
const { calls, prompt } = createPathSensitivePrompt()
|
||||||
|
const client = { session: { promptAsync: prompt } }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await dispatchInternalPrompt<CompatPromptInput>({
|
||||||
|
mode: "async",
|
||||||
|
client,
|
||||||
|
sessionID: "ses_async_path_compat",
|
||||||
|
source: "test:path-compat:async",
|
||||||
|
settleMs: 0,
|
||||||
|
checkStatus: false,
|
||||||
|
checkToolState: false,
|
||||||
|
queueBehavior: "defer",
|
||||||
|
input: {
|
||||||
|
path: { id: "ses_async_path_compat" },
|
||||||
|
body: { parts: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("dispatched")
|
||||||
|
expect(calls.map((call) => call.path)).toEqual([
|
||||||
|
{ id: "ses_async_path_compat" },
|
||||||
|
"ses_async_path_compat",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -68,6 +68,47 @@ function createDefaultDedupeKey(source: string, input: unknown): string {
|
|||||||
return `${source}:${fingerprint.length}:${fingerprint.slice(0, 8192)}`
|
return `${source}:${fingerprint.length}:${fingerprint.slice(0, 8192)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ObjectPathPromptInput = {
|
||||||
|
readonly path?: { readonly id?: string } | string
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasObjectSessionPath(input: unknown): input is ObjectPathPromptInput & { readonly path: { readonly id: string } } {
|
||||||
|
return typeof input === "object"
|
||||||
|
&& input !== null
|
||||||
|
&& "path" in input
|
||||||
|
&& typeof input.path === "object"
|
||||||
|
&& input.path !== null
|
||||||
|
&& "id" in input.path
|
||||||
|
&& typeof input.path.id === "string"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectPathTypeError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === "string" ? error : ""
|
||||||
|
return message.includes('The "path" property must be of type string') && message.includes("got object")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchWithPathCompatibility<TInput>(
|
||||||
|
dispatch: (dispatchInput: TInput) => Promise<unknown>,
|
||||||
|
input: TInput,
|
||||||
|
): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await dispatch(input)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isObjectPathTypeError(error) || !hasObjectSessionPath(input)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryInput = {
|
||||||
|
...input,
|
||||||
|
path: input.path.id,
|
||||||
|
} as TInput
|
||||||
|
return dispatch(retryInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||||
args: InternalPromptDispatchArgs<TInput>,
|
args: InternalPromptDispatchArgs<TInput>,
|
||||||
): Promise<InternalPromptDispatchResult> {
|
): Promise<InternalPromptDispatchResult> {
|
||||||
@@ -131,7 +172,7 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
|||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
checkStatus: args.checkStatus !== false,
|
checkStatus: args.checkStatus !== false,
|
||||||
checkToolState: args.checkToolState !== false,
|
checkToolState: args.checkToolState !== false,
|
||||||
dispatch,
|
dispatch: (dispatchInput) => dispatchWithPathCompatibility(dispatch, dispatchInput),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +191,7 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
|||||||
queueRetryMs,
|
queueRetryMs,
|
||||||
checkStatus: args.checkStatus !== false,
|
checkStatus: args.checkStatus !== false,
|
||||||
checkToolState: args.checkToolState !== false,
|
checkToolState: args.checkToolState !== false,
|
||||||
dispatch: async (_dispatchInput: unknown) => dispatch(input),
|
dispatch: async (_dispatchInput: unknown) => dispatchWithPathCompatibility(dispatch, input),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +207,7 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
|||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
checkStatus: args.checkStatus !== false,
|
checkStatus: args.checkStatus !== false,
|
||||||
checkToolState: args.checkToolState !== false,
|
checkToolState: args.checkToolState !== false,
|
||||||
dispatch,
|
dispatch: (dispatchInput) => dispatchWithPathCompatibility(dispatch, dispatchInput),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
export type PromptSessionPath = { readonly id?: string } | string
|
||||||
|
|
||||||
export type PromptAsyncInput = {
|
export type PromptAsyncInput = {
|
||||||
readonly path?: { readonly id?: string }
|
readonly path?: PromptSessionPath
|
||||||
readonly body?: unknown
|
readonly body?: unknown
|
||||||
readonly query?: unknown
|
readonly query?: unknown
|
||||||
readonly signal?: unknown
|
readonly signal?: unknown
|
||||||
|
|||||||
Reference in New Issue
Block a user