fix(prompt-async-gate): retry object-form path on runtime type error (#4417)
Atlas task() with run_in_background=false crashed with 'The "path" property must be of type string, got object' from Node's path.isAbsolute validation upstream of the SDK.
All callers (sync-prompt-sender, boulder-continuation-injector, idle-event, session-route, model-suggestion-retry) already route through dispatchInternalPrompt, so centralizing the compatibility shim in prompt-async-gate.ts covers every Bug 1 site without touching individual hooks.
On TypeError matching the object-path signature, retry once with path collapsed to its id string. Types broaden PromptSessionPath to string | { id }.
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,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)}`
|
||||
}
|
||||
|
||||
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>(
|
||||
args: InternalPromptDispatchArgs<TInput>,
|
||||
): Promise<InternalPromptDispatchResult> {
|
||||
@@ -131,7 +172,7 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
checkToolState: args.checkToolState !== false,
|
||||
dispatch,
|
||||
dispatch: (dispatchInput) => dispatchWithPathCompatibility(dispatch, dispatchInput),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,7 +191,7 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||
queueRetryMs,
|
||||
checkStatus: args.checkStatus !== 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,
|
||||
checkStatus: args.checkStatus !== 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 = {
|
||||
readonly path?: { readonly id?: string }
|
||||
readonly path?: PromptSessionPath
|
||||
readonly body?: unknown
|
||||
readonly query?: unknown
|
||||
readonly signal?: unknown
|
||||
|
||||
Reference in New Issue
Block a user