Merge pull request #3467 from code-yeongyu/refactor/delegate-task-metadata-hardening
refactor: harden delegate-task metadata pipeline
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
|
||||
export { waitForTaskSessionID } from "./wait-for-task-session"
|
||||
export type { WaitForTaskSessionIDOptions } from "./wait-for-task-session"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { BackgroundTaskStatus } from "./types"
|
||||
import { waitForTaskSessionID } from "./wait-for-task-session"
|
||||
|
||||
interface TaskSnapshot {
|
||||
sessionID?: string
|
||||
status?: BackgroundTaskStatus
|
||||
}
|
||||
|
||||
function createManager(responses: TaskSnapshot[]) {
|
||||
let index = 0
|
||||
|
||||
return {
|
||||
getTask(_taskID: string): TaskSnapshot {
|
||||
const response = responses[Math.min(index, responses.length - 1)]
|
||||
index += 1
|
||||
return response
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("waitForTaskSessionID", () => {
|
||||
test("#given task already has a session id #when waiting #then it returns immediately", async () => {
|
||||
// given
|
||||
const manager = createManager([{ sessionID: "ses_ready_123", status: "running" }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_ready")
|
||||
|
||||
// then
|
||||
expect(sessionID).toBe("ses_ready_123")
|
||||
})
|
||||
|
||||
test("#given session appears later #when waiting #then it polls until resolved", async () => {
|
||||
// given
|
||||
const manager = createManager([
|
||||
{ status: "running" },
|
||||
{ status: "running" },
|
||||
{ sessionID: "ses_late_123", status: "running" },
|
||||
])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_late", {
|
||||
intervalMs: 1,
|
||||
timeoutMs: 20,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(sessionID).toBe("ses_late_123")
|
||||
})
|
||||
|
||||
test("#given aborted signal #when waiting #then it returns undefined", async () => {
|
||||
// given
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const manager = createManager([{ status: "running" }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_abort", {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(sessionID).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given task never resolves #when waiting past timeout #then it returns undefined", async () => {
|
||||
// given
|
||||
const manager = createManager([{ status: "running" }, { status: "running" }, { status: "running" }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, "bg_timeout", {
|
||||
intervalMs: 1,
|
||||
timeoutMs: 3,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(sessionID).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each(["error", "cancelled", "interrupt"] satisfies BackgroundTaskStatus[])(
|
||||
"#given %s task state #when waiting #then it returns undefined",
|
||||
async (status: BackgroundTaskStatus) => {
|
||||
// given
|
||||
const manager = createManager([{ status }])
|
||||
|
||||
// when
|
||||
const sessionID = await waitForTaskSessionID(manager, `bg_${status}`)
|
||||
|
||||
// then
|
||||
expect(sessionID).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { getTimingConfig } from "../../tools/delegate-task/timing"
|
||||
import type { BackgroundTaskStatus } from "./types"
|
||||
|
||||
type SessionWaitTerminalStatus = Extract<BackgroundTaskStatus, "error" | "cancelled" | "interrupt">
|
||||
type AbortSignalLike = { aborted: boolean }
|
||||
|
||||
interface TaskReader {
|
||||
getTask(taskID: string): { sessionID?: string; status?: BackgroundTaskStatus } | undefined
|
||||
}
|
||||
|
||||
export interface WaitForTaskSessionIDOptions {
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
signal?: AbortSignalLike
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: BackgroundTaskStatus | undefined): status is SessionWaitTerminalStatus {
|
||||
return status === "error" || status === "cancelled" || status === "interrupt"
|
||||
}
|
||||
|
||||
function waitForInterval(intervalMs: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const scheduler = globalThis as { setTimeout: (handler: () => void, timeout?: number) => unknown }
|
||||
scheduler.setTimeout(resolve, intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForTaskSessionID(
|
||||
manager: TaskReader,
|
||||
taskID: string,
|
||||
options: WaitForTaskSessionIDOptions = {}
|
||||
): Promise<string | undefined> {
|
||||
const timing = getTimingConfig()
|
||||
const timeoutMs = options.timeoutMs ?? timing.WAIT_FOR_SESSION_TIMEOUT_MS
|
||||
const intervalMs = options.intervalMs ?? timing.WAIT_FOR_SESSION_INTERVAL_MS
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const initialTask = manager.getTask(taskID)
|
||||
if (initialTask?.sessionID) {
|
||||
return initialTask.sessionID
|
||||
}
|
||||
if (isTerminalStatus(initialTask?.status)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (options.signal?.aborted) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
await waitForInterval(intervalMs)
|
||||
|
||||
const task = manager.getTask(taskID)
|
||||
if (task?.sessionID) {
|
||||
return task.sessionID
|
||||
}
|
||||
if (isTerminalStatus(task?.status)) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -5,3 +5,10 @@ export {
|
||||
storeToolMetadata,
|
||||
} from "./store"
|
||||
export type { PendingToolMetadata } from "./store"
|
||||
export { resolveToolCallID } from "./resolve-tool-call-id"
|
||||
export type { ToolCallIDCarrier } from "./resolve-tool-call-id"
|
||||
export { buildTaskMetadataBlock, extractTaskLink, parseTaskMetadataBlock } from "./task-metadata-contract"
|
||||
export type { TaskLink } from "./task-metadata-contract"
|
||||
export { publishToolMetadata } from "./publish-tool-metadata"
|
||||
export { recoverToolMetadata } from "./recover-tool-metadata"
|
||||
export type { ToolMetadataPublisherContext } from "./publish-tool-metadata"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { clearPendingStore, getPendingStoreSize } from "./store"
|
||||
import { publishToolMetadata } from "./publish-tool-metadata"
|
||||
import { recoverToolMetadata } from "./recover-tool-metadata"
|
||||
|
||||
describe("tool-metadata-store integration", () => {
|
||||
beforeEach(() => {
|
||||
clearPendingStore()
|
||||
})
|
||||
|
||||
test("#given stored metadata #when publishing then recovering #then the round trip preserves the payload", async () => {
|
||||
// given
|
||||
const payload = { title: "Task", metadata: { sessionId: "ses_child" } }
|
||||
|
||||
// when
|
||||
await publishToolMetadata({ sessionID: "ses_parent", callID: "call_123" }, payload)
|
||||
const recovered = recoverToolMetadata("ses_parent", { callID: "call_123" })
|
||||
|
||||
// then
|
||||
expect(recovered).toEqual(payload)
|
||||
})
|
||||
|
||||
test("#given call id casing mismatch #when publishing and recovering #then canonical resolution still matches", async () => {
|
||||
// given
|
||||
const payload = { title: "Task", metadata: { sessionId: "ses_child" } }
|
||||
|
||||
// when
|
||||
await publishToolMetadata({ sessionID: "ses_parent", callId: "call_case" }, payload)
|
||||
const recovered = recoverToolMetadata("ses_parent", { callID: "call_case" })
|
||||
|
||||
// then
|
||||
expect(recovered).toEqual(payload)
|
||||
})
|
||||
|
||||
test("#given blank call id #when publishing #then nothing is stored", async () => {
|
||||
// given
|
||||
const payload = { title: "Task" }
|
||||
|
||||
// when
|
||||
const result = await publishToolMetadata({ sessionID: "ses_parent", callID: " " }, payload)
|
||||
const recovered = recoverToolMetadata("ses_parent", { callID: "call_blank" })
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ stored: false })
|
||||
expect(recovered).toBeUndefined()
|
||||
expect(getPendingStoreSize()).toBe(0)
|
||||
})
|
||||
|
||||
test("#given missing call id #when publishing #then nothing is stored", async () => {
|
||||
// given
|
||||
const payload = { title: "Task" }
|
||||
|
||||
// when
|
||||
const result = await publishToolMetadata({ sessionID: "ses_parent" }, payload)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ stored: false })
|
||||
expect(getPendingStoreSize()).toBe(0)
|
||||
})
|
||||
|
||||
test("#given same session with different call ids #when publishing twice #then each entry stays isolated", async () => {
|
||||
// given
|
||||
await publishToolMetadata({ sessionID: "ses_parent", callID: "call_a" }, { title: "A" })
|
||||
await publishToolMetadata({ sessionID: "ses_parent", callID: "call_b" }, { title: "B" })
|
||||
|
||||
// when
|
||||
const recoveredA = recoverToolMetadata("ses_parent", { callID: "call_a" })
|
||||
const recoveredB = recoverToolMetadata("ses_parent", { callID: "call_b" })
|
||||
|
||||
// then
|
||||
expect(recoveredA).toEqual({ title: "A" })
|
||||
expect(recoveredB).toEqual({ title: "B" })
|
||||
})
|
||||
|
||||
test("#given stale metadata #when a fresh entry is stored after the timeout #then stale entries are cleaned up", async () => {
|
||||
// given
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
await publishToolMetadata({ sessionID: "ses_parent", callID: "call_old" }, { title: "Old" })
|
||||
now = 15 * 60 * 1000 + 1
|
||||
|
||||
// when
|
||||
await publishToolMetadata({ sessionID: "ses_parent", callID: "call_new" }, { title: "New" })
|
||||
|
||||
// then
|
||||
expect(recoverToolMetadata("ses_parent", { callID: "call_old" })).toBeUndefined()
|
||||
expect(recoverToolMetadata("ses_parent", { callID: "call_new" })).toEqual({ title: "New" })
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { clearPendingStore, consumeToolMetadata } from "./store"
|
||||
import { publishToolMetadata } from "./publish-tool-metadata"
|
||||
|
||||
describe("publishToolMetadata", () => {
|
||||
beforeEach(() => {
|
||||
clearPendingStore()
|
||||
})
|
||||
|
||||
test("#given metadata context and call id #when publishing #then it awaits metadata and stores the payload", async () => {
|
||||
// given
|
||||
const calls: string[] = []
|
||||
let metadataFinished = false
|
||||
const payload = { title: "Task", metadata: { sessionId: "ses_child" } }
|
||||
|
||||
// when
|
||||
const result = await publishToolMetadata(
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_123",
|
||||
metadata: async input => {
|
||||
calls.push(input.title ?? "")
|
||||
await new Promise(resolve => setTimeout(resolve, 1))
|
||||
metadataFinished = true
|
||||
},
|
||||
},
|
||||
payload
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ stored: true })
|
||||
expect(metadataFinished).toBe(true)
|
||||
expect(calls).toEqual(["Task"])
|
||||
expect(consumeToolMetadata("ses_parent", "call_123")).toEqual(payload)
|
||||
})
|
||||
|
||||
test("#given legacy call id variant #when publishing #then it stores with the canonical resolver", async () => {
|
||||
// given
|
||||
const payload = { title: "Task", metadata: { sessionId: "ses_child" } }
|
||||
|
||||
// when
|
||||
const result = await publishToolMetadata(
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callId: " call_legacy ",
|
||||
},
|
||||
payload
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ stored: true })
|
||||
expect(consumeToolMetadata("ses_parent", "call_legacy")).toEqual(payload)
|
||||
})
|
||||
|
||||
test("#given missing call id #when publishing #then it still emits metadata but skips storing", async () => {
|
||||
// given
|
||||
let metadataCalls = 0
|
||||
|
||||
// when
|
||||
const result = await publishToolMetadata(
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
metadata: () => {
|
||||
metadataCalls += 1
|
||||
},
|
||||
},
|
||||
{ title: "Task" }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ stored: false })
|
||||
expect(metadataCalls).toBe(1)
|
||||
expect(consumeToolMetadata("ses_parent", "call_missing")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveToolCallID, type ToolCallIDCarrier } from "./resolve-tool-call-id"
|
||||
import { storeToolMetadata, type PendingToolMetadata } from "./store"
|
||||
|
||||
export interface ToolMetadataPublisherContext extends ToolCallIDCarrier {
|
||||
sessionID: string
|
||||
metadata?: (input: PendingToolMetadata) => void | Promise<void>
|
||||
}
|
||||
|
||||
export async function publishToolMetadata(
|
||||
ctx: ToolMetadataPublisherContext,
|
||||
payload: PendingToolMetadata
|
||||
): Promise<{ stored: boolean }> {
|
||||
await ctx.metadata?.(payload)
|
||||
|
||||
const callID = resolveToolCallID(ctx)
|
||||
if (!callID) {
|
||||
log("[tool-metadata-store] Skipping metadata store publish because tool call ID is unavailable", {
|
||||
sessionID: ctx.sessionID,
|
||||
hasTitle: typeof payload.title === "string",
|
||||
hasMetadata: payload.metadata !== undefined,
|
||||
})
|
||||
return { stored: false }
|
||||
}
|
||||
|
||||
storeToolMetadata(ctx.sessionID, callID, payload)
|
||||
return { stored: true }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { recoverToolMetadata } from "./recover-tool-metadata"
|
||||
import { clearPendingStore, storeToolMetadata } from "./store"
|
||||
|
||||
describe("recoverToolMetadata", () => {
|
||||
beforeEach(() => {
|
||||
clearPendingStore()
|
||||
})
|
||||
|
||||
test("#given stored metadata and call id variant #when recovering #then it finds the stored payload", () => {
|
||||
// given
|
||||
const payload = { title: "Recovered", metadata: { sessionId: "ses_child" } }
|
||||
storeToolMetadata("ses_parent", "call_123", payload)
|
||||
|
||||
// when
|
||||
const recovered = recoverToolMetadata("ses_parent", { callId: " call_123 " })
|
||||
|
||||
// then
|
||||
expect(recovered).toEqual(payload)
|
||||
})
|
||||
|
||||
test("#given direct string call id #when recovering #then it consumes the stored payload", () => {
|
||||
// given
|
||||
const payload = { title: "Recovered" }
|
||||
storeToolMetadata("ses_parent", "call_456", payload)
|
||||
|
||||
// when
|
||||
const recovered = recoverToolMetadata("ses_parent", "call_456")
|
||||
|
||||
// then
|
||||
expect(recovered).toEqual(payload)
|
||||
})
|
||||
|
||||
test("#given missing or blank call id #when recovering #then it returns undefined", () => {
|
||||
// given
|
||||
storeToolMetadata("ses_parent", "call_789", { title: "Recovered" })
|
||||
|
||||
// when
|
||||
const missing = recoverToolMetadata("ses_parent", undefined)
|
||||
const blank = recoverToolMetadata("ses_parent", { callID: " " })
|
||||
|
||||
// then
|
||||
expect(missing).toBeUndefined()
|
||||
expect(blank).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { consumeToolMetadata, type PendingToolMetadata } from "./store"
|
||||
import { resolveToolCallID, type ToolCallIDCarrier } from "./resolve-tool-call-id"
|
||||
|
||||
export function recoverToolMetadata(
|
||||
sessionID: string,
|
||||
source: ToolCallIDCarrier | string | undefined
|
||||
): PendingToolMetadata | undefined {
|
||||
if (typeof source === "string") {
|
||||
return consumeToolMetadata(sessionID, source)
|
||||
}
|
||||
|
||||
const callID = source ? resolveToolCallID(source) : undefined
|
||||
if (!callID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return consumeToolMetadata(sessionID, callID)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { resolveToolCallID, type ToolCallIDCarrier } from "./resolve-tool-call-id"
|
||||
|
||||
describe("resolveToolCallID", () => {
|
||||
function makeCtx(overrides: Partial<ToolCallIDCarrier> = {}): ToolCallIDCarrier {
|
||||
return {
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("#given callID is set #when resolving #then it returns callID", () => {
|
||||
// given
|
||||
const ctx = makeCtx({ callID: "call_abc" })
|
||||
|
||||
// when
|
||||
const result = resolveToolCallID(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBe("call_abc")
|
||||
})
|
||||
|
||||
test("#given only callId is set #when resolving #then it returns callId", () => {
|
||||
// given
|
||||
const ctx = makeCtx({ callId: "call_def" })
|
||||
|
||||
// when
|
||||
const result = resolveToolCallID(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBe("call_def")
|
||||
})
|
||||
|
||||
test("#given only call_id is set #when resolving #then it returns call_id", () => {
|
||||
// given
|
||||
const ctx = makeCtx({ call_id: "call_ghi" })
|
||||
|
||||
// when
|
||||
const result = resolveToolCallID(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBe("call_ghi")
|
||||
})
|
||||
|
||||
test("#given surrounding whitespace #when resolving #then it trims the value", () => {
|
||||
// given
|
||||
const ctx = makeCtx({ callID: " call_trimmed " })
|
||||
|
||||
// when
|
||||
const result = resolveToolCallID(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBe("call_trimmed")
|
||||
})
|
||||
|
||||
test("#given blank callID #when resolving #then it returns undefined", () => {
|
||||
// given
|
||||
const ctx = makeCtx({ callID: "" })
|
||||
|
||||
// when
|
||||
const result = resolveToolCallID(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given whitespace callID #when resolving #then it returns undefined", () => {
|
||||
// given
|
||||
const ctx = makeCtx({ callID: " " })
|
||||
|
||||
// when
|
||||
const result = resolveToolCallID(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given no call id variants #when resolving #then it returns undefined", () => {
|
||||
// given
|
||||
const ctx = makeCtx()
|
||||
|
||||
// when
|
||||
const result = resolveToolCallID(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface ToolCallIDCarrier {
|
||||
callID?: string
|
||||
callId?: string
|
||||
call_id?: string
|
||||
}
|
||||
|
||||
function normalizeCallID(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
return trimmed === "" ? undefined : trimmed
|
||||
}
|
||||
|
||||
export function resolveToolCallID(ctx: ToolCallIDCarrier): string | undefined {
|
||||
const resolved = normalizeCallID(ctx.callID) ?? normalizeCallID(ctx.callId) ?? normalizeCallID(ctx.call_id)
|
||||
|
||||
if (!resolved) {
|
||||
log("[tool-metadata-store] Missing tool call ID for metadata correlation")
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { buildTaskMetadataBlock, extractTaskLink, parseTaskMetadataBlock } from "./task-metadata-contract"
|
||||
|
||||
describe("buildTaskMetadataBlock", () => {
|
||||
test("#given only session id #when building #then it preserves the frozen block format", () => {
|
||||
// given
|
||||
const link = { sessionId: "ses_abc" }
|
||||
|
||||
// when
|
||||
const block = buildTaskMetadataBlock(link)
|
||||
|
||||
// then
|
||||
expect(block).toBe("<task_metadata>\nsession_id: ses_abc\n</task_metadata>")
|
||||
})
|
||||
|
||||
test("#given extended task metadata #when building #then it emits optional lines in order", () => {
|
||||
// given
|
||||
const link = {
|
||||
sessionId: "ses_bg_123",
|
||||
taskId: "bg_123",
|
||||
backgroundTaskId: "bg_123",
|
||||
agent: "explore",
|
||||
category: "quick",
|
||||
}
|
||||
|
||||
// when
|
||||
const block = buildTaskMetadataBlock(link)
|
||||
|
||||
// then
|
||||
expect(block).toBe(
|
||||
"<task_metadata>\nsession_id: ses_bg_123\ntask_id: bg_123\nbackground_task_id: bg_123\nsubagent: explore\ncategory: quick\n</task_metadata>"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseTaskMetadataBlock", () => {
|
||||
test("#given a task metadata block #when parsing #then it extracts the structured link", () => {
|
||||
// given
|
||||
const text = "<task_metadata>\nsession_id: ses_sync_123\ntask_id: task_123\nbackground_task_id: bg_123\nsubagent: oracle\ncategory: deep\n</task_metadata>"
|
||||
|
||||
// when
|
||||
const parsed = parseTaskMetadataBlock(text)
|
||||
|
||||
// then
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "ses_sync_123",
|
||||
taskId: "task_123",
|
||||
backgroundTaskId: "bg_123",
|
||||
agent: "oracle",
|
||||
category: "deep",
|
||||
})
|
||||
})
|
||||
|
||||
test("#given text without metadata #when parsing #then it returns an empty link", () => {
|
||||
// given
|
||||
const text = "Task completed without metadata"
|
||||
|
||||
// when
|
||||
const parsed = parseTaskMetadataBlock(text)
|
||||
|
||||
// then
|
||||
expect(parsed).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractTaskLink", () => {
|
||||
test("#given metadata session aliases #when extracting #then metadata wins over output text", () => {
|
||||
// given
|
||||
const metadata = {
|
||||
sessionID: "ses_meta_123",
|
||||
task_id: "task_meta_123",
|
||||
background_task_id: "bg_meta_123",
|
||||
subagent: "atlas",
|
||||
category: "unspecified-high",
|
||||
}
|
||||
const output = "<task_metadata>\nsession_id: ses_text_456\n</task_metadata>"
|
||||
|
||||
// when
|
||||
const extracted = extractTaskLink(metadata, output)
|
||||
|
||||
// then
|
||||
expect(extracted).toEqual({
|
||||
sessionId: "ses_meta_123",
|
||||
taskId: "task_meta_123",
|
||||
backgroundTaskId: "bg_meta_123",
|
||||
agent: "atlas",
|
||||
category: "unspecified-high",
|
||||
})
|
||||
})
|
||||
|
||||
test("#given missing metadata #when extracting #then it falls back to task metadata text", () => {
|
||||
// given
|
||||
const output = "Task completed.\n\n<task_metadata>\nsession_id: ses_text_456\nsubagent: oracle\n</task_metadata>"
|
||||
|
||||
// when
|
||||
const extracted = extractTaskLink(undefined, output)
|
||||
|
||||
// then
|
||||
expect(extracted).toEqual({
|
||||
sessionId: "ses_text_456",
|
||||
agent: "oracle",
|
||||
})
|
||||
})
|
||||
|
||||
test("#given explicit session id output #when extracting #then it preserves Session ID compatibility", () => {
|
||||
// given
|
||||
const output = "Background task launched.\n\nSession ID: ses_bg_789"
|
||||
|
||||
// when
|
||||
const extracted = extractTaskLink(undefined, output)
|
||||
|
||||
// then
|
||||
expect(extracted).toEqual({ sessionId: "ses_bg_789" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface TaskLink {
|
||||
sessionId?: string
|
||||
taskId?: string
|
||||
backgroundTaskId?: string
|
||||
agent?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
return trimmed === "" ? undefined : trimmed
|
||||
}
|
||||
|
||||
function readSessionIdFromMetadata(metadata: Record<string, unknown>): string | undefined {
|
||||
return readString(metadata.sessionId) ?? readString(metadata.sessionID) ?? readString(metadata.session_id)
|
||||
}
|
||||
|
||||
function readTaskIdFromMetadata(metadata: Record<string, unknown>): string | undefined {
|
||||
return readString(metadata.taskId) ?? readString(metadata.taskID) ?? readString(metadata.task_id)
|
||||
}
|
||||
|
||||
function readBackgroundTaskIdFromMetadata(metadata: Record<string, unknown>): string | undefined {
|
||||
return readString(metadata.backgroundTaskId)
|
||||
?? readString(metadata.backgroundTaskID)
|
||||
?? readString(metadata.background_task_id)
|
||||
}
|
||||
|
||||
function readAgentFromMetadata(metadata: Record<string, unknown>): string | undefined {
|
||||
return readString(metadata.agent) ?? readString(metadata.subagent)
|
||||
}
|
||||
|
||||
function readCategoryFromMetadata(metadata: Record<string, unknown>): string | undefined {
|
||||
return readString(metadata.category)
|
||||
}
|
||||
|
||||
function extractTaskMetadataContent(text: string): string | undefined {
|
||||
const blocks = [...text.matchAll(/<task_metadata>([\s\S]*?)<\/task_metadata>/gi)]
|
||||
return blocks.at(-1)?.[1]
|
||||
}
|
||||
|
||||
function extractExplicitSessionId(text: string): string | undefined {
|
||||
const matches = [...text.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_-]+)/g)]
|
||||
return matches.at(-1)?.[1]
|
||||
}
|
||||
|
||||
export function buildTaskMetadataBlock(link: TaskLink): string {
|
||||
const lines: string[] = []
|
||||
|
||||
if (link.sessionId) {
|
||||
lines.push(`session_id: ${link.sessionId}`)
|
||||
}
|
||||
if (link.taskId) {
|
||||
lines.push(`task_id: ${link.taskId}`)
|
||||
}
|
||||
if (link.backgroundTaskId) {
|
||||
lines.push(`background_task_id: ${link.backgroundTaskId}`)
|
||||
}
|
||||
if (link.agent) {
|
||||
lines.push(`subagent: ${link.agent}`)
|
||||
}
|
||||
if (link.category) {
|
||||
lines.push(`category: ${link.category}`)
|
||||
}
|
||||
|
||||
return `<task_metadata>\n${lines.join("\n")}\n</task_metadata>`
|
||||
}
|
||||
|
||||
export function parseTaskMetadataBlock(text: string): TaskLink {
|
||||
const blockContent = extractTaskMetadataContent(text) ?? text
|
||||
const lines = blockContent
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const parsed: TaskLink = {}
|
||||
|
||||
for (const line of lines) {
|
||||
const separatorIndex = line.indexOf(":")
|
||||
if (separatorIndex === -1) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim().toLowerCase()
|
||||
const value = readString(line.slice(separatorIndex + 1))
|
||||
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === "session_id") {
|
||||
parsed.sessionId = value
|
||||
} else if (key === "task_id") {
|
||||
parsed.taskId = value
|
||||
} else if (key === "background_task_id") {
|
||||
parsed.backgroundTaskId = value
|
||||
} else if (key === "subagent" || key === "agent") {
|
||||
parsed.agent = value
|
||||
} else if (key === "category") {
|
||||
parsed.category = value
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function extractTaskLink(metadata: unknown, outputText: string): TaskLink {
|
||||
if (isRecord(metadata)) {
|
||||
const metadataLink: TaskLink = {
|
||||
sessionId: readSessionIdFromMetadata(metadata),
|
||||
taskId: readTaskIdFromMetadata(metadata),
|
||||
backgroundTaskId: readBackgroundTaskIdFromMetadata(metadata),
|
||||
agent: readAgentFromMetadata(metadata),
|
||||
category: readCategoryFromMetadata(metadata),
|
||||
}
|
||||
|
||||
if (metadataLink.sessionId || metadataLink.taskId || metadataLink.backgroundTaskId || metadataLink.agent || metadataLink.category) {
|
||||
return metadataLink
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseTaskMetadataBlock(outputText)
|
||||
if (parsed.sessionId || parsed.taskId || parsed.backgroundTaskId || parsed.agent || parsed.category) {
|
||||
log("[tool-metadata-store] Falling back to <task_metadata> parsing")
|
||||
return parsed
|
||||
}
|
||||
|
||||
const explicitSessionId = extractExplicitSessionId(outputText)
|
||||
if (explicitSessionId) {
|
||||
log("[tool-metadata-store] Falling back to explicit Session ID parsing")
|
||||
return { sessionId: explicitSessionId }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -90,6 +90,17 @@ describe("extractSessionIdFromMetadata", () => {
|
||||
expect(result).toBe("ses_plugin_abc123")
|
||||
})
|
||||
|
||||
test("extracts legacy session aliases from tool metadata object", () => {
|
||||
// given
|
||||
const metadata = { sessionID: "ses_plugin_alias_123" }
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromMetadata(metadata)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_plugin_alias_123")
|
||||
})
|
||||
|
||||
test("returns undefined for metadata without sessionId", () => {
|
||||
// given
|
||||
const metadata = { title: "some task" }
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { extractTaskLink } from "../../features/tool-metadata-store"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
|
||||
export function extractSessionIdFromMetadata(metadata: unknown): string | undefined {
|
||||
if (metadata && typeof metadata === "object" && "sessionId" in metadata) {
|
||||
const value = (metadata as Record<string, unknown>).sessionId
|
||||
if (typeof value === "string" && value.startsWith("ses_")) {
|
||||
return value
|
||||
}
|
||||
const sessionId = extractTaskLink(metadata, "").sessionId
|
||||
if (typeof sessionId === "string" && sessionId.startsWith("ses_")) {
|
||||
return sessionId
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function extractSessionIdFromOutput(output: string): string | undefined {
|
||||
const taskMetadataBlocks = [...output.matchAll(/<task_metadata>([\s\S]*?)<\/task_metadata>/gi)]
|
||||
const lastTaskMetadataBlock = taskMetadataBlocks.at(-1)?.[1]
|
||||
if (lastTaskMetadataBlock) {
|
||||
const taskMetadataSessionMatch = lastTaskMetadataBlock.match(/session_id:\s*(ses_[a-zA-Z0-9_-]+)/i)
|
||||
if (taskMetadataSessionMatch) {
|
||||
return taskMetadataSessionMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
const explicitSessionMatches = [...output.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_-]+)/g)]
|
||||
return explicitSessionMatches.at(-1)?.[1]
|
||||
return extractTaskLink(undefined, output).sessionId
|
||||
}
|
||||
|
||||
export async function validateSubagentSessionId(input: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractTaskLink } from "../../features/tool-metadata-store"
|
||||
import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names"
|
||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||
|
||||
@@ -9,8 +10,6 @@ export interface OracleVerificationEvidence {
|
||||
|
||||
const AGENT_LINE_PATTERN = /^Agent:[ \t]*(\S+)$/im
|
||||
const PROMISE_TAG_PATTERN = /<promise>[ \t]*(\S+?)[ \t]*<\/promise>/is
|
||||
const TASK_METADATA_PATTERN = /<task_metadata>[ \t]*([\s\S]*?)[ \t]*<\/task_metadata>/is
|
||||
const SESSION_ID_LINE_PATTERN = /^session_id:[ \t]*(\S+)$/im
|
||||
|
||||
export function parseOracleVerificationEvidence(text: string): OracleVerificationEvidence | undefined {
|
||||
const trimmedText = text.trim()
|
||||
@@ -36,17 +35,9 @@ export function parseOracleVerificationEvidence(text: string): OracleVerificatio
|
||||
return undefined
|
||||
}
|
||||
|
||||
const metadataMatch = trimmedText.match(TASK_METADATA_PATTERN)
|
||||
let sessionID: string | undefined
|
||||
if (metadataMatch) {
|
||||
const metadataContent = metadataMatch[1]
|
||||
const sessionIDMatch = metadataContent.match(SESSION_ID_LINE_PATTERN)
|
||||
if (sessionIDMatch) {
|
||||
sessionID = sessionIDMatch[1]?.trim()
|
||||
}
|
||||
}
|
||||
const sessionID = extractTaskLink(undefined, trimmedText).sessionId
|
||||
|
||||
return { agent, promise, sessionID }
|
||||
return { agent, promise, sessionID }
|
||||
}
|
||||
|
||||
export function isOracleVerified(text: string): boolean {
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { extractTaskLink } from "../../features/tool-metadata-store"
|
||||
|
||||
const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent"]
|
||||
|
||||
const SESSION_ID_PATTERNS = [
|
||||
/Session ID: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/<task_metadata>\s*session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/sessionId: (ses_[a-zA-Z0-9_-]+)/,
|
||||
]
|
||||
|
||||
function extractSessionId(output: string): string | null {
|
||||
for (const pattern of SESSION_ID_PATTERNS) {
|
||||
const match = output.match(pattern)
|
||||
if (match) return match[1] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createTaskResumeInfoHook() {
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
@@ -25,7 +12,7 @@ export function createTaskResumeInfoHook() {
|
||||
if (outputText.startsWith("Error:") || outputText.startsWith("Failed")) return
|
||||
if (outputText.includes("\nto continue:")) return
|
||||
|
||||
const sessionId = extractSessionId(outputText)
|
||||
const sessionId = extractTaskLink(output.metadata, outputText).sessionId
|
||||
if (!sessionId) return
|
||||
|
||||
output.output =
|
||||
|
||||
@@ -78,6 +78,24 @@ describe("createTaskResumeInfoHook", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given target tool with session metadata object", () => {
|
||||
describe("#when output text omits session ID but metadata includes it", () => {
|
||||
it("#then should append resume info from metadata", async () => {
|
||||
const input = createInput("task")
|
||||
const output = {
|
||||
title: "task",
|
||||
output: "Task completed successfully",
|
||||
metadata: { sessionID: "ses_meta_123" },
|
||||
}
|
||||
|
||||
await afterHook(input, output)
|
||||
|
||||
expect(output.output).toContain("to continue:")
|
||||
expect(output.output).toContain("ses_meta_123")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given target tool with error output", () => {
|
||||
describe("#when output starts with Error:", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { beforeEach, describe, expect, it } from "bun:test"
|
||||
|
||||
import { clearPendingStore, storeToolMetadata } from "../features/tool-metadata-store"
|
||||
import { createToolExecuteAfterHandler } from "./tool-execute-after"
|
||||
|
||||
describe("createToolExecuteAfterHandler", () => {
|
||||
beforeEach(() => {
|
||||
clearPendingStore()
|
||||
})
|
||||
|
||||
it("#given truncator changes output #when tool.execute.after runs #then claudeCodeHooks receives truncated output", async () => {
|
||||
const callOrder: string[] = []
|
||||
let claudeSawOutput = ""
|
||||
@@ -32,4 +38,58 @@ describe("createToolExecuteAfterHandler", () => {
|
||||
expect(callOrder).toEqual(["truncator", "claude"])
|
||||
expect(claudeSawOutput).toBe("truncated output")
|
||||
})
|
||||
|
||||
it("#given stored metadata with legacy call id casing #when tool.execute.after runs #then it restores the stored metadata", async () => {
|
||||
// given
|
||||
storeToolMetadata("ses_parent", "call_legacy", {
|
||||
title: "stored title",
|
||||
metadata: { sessionId: "ses_child", agent: "oracle" },
|
||||
})
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
hooks: {} as never,
|
||||
})
|
||||
|
||||
const output = { title: "result", output: "original output", metadata: { truncated: true } }
|
||||
|
||||
// when
|
||||
await handler(
|
||||
{ tool: "hashline_edit", sessionID: "ses_parent", callId: " call_legacy " },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.title).toBe("stored title")
|
||||
expect(output.metadata).toEqual({ truncated: true, sessionId: "ses_child", agent: "oracle" })
|
||||
})
|
||||
|
||||
it("#given native session metadata #when stored metadata exists #then stored metadata does not overwrite native session linkage", async () => {
|
||||
// given
|
||||
storeToolMetadata("ses_parent", "call_native", {
|
||||
title: "stored title",
|
||||
metadata: { sessionId: "ses_stored", agent: "oracle" },
|
||||
})
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
hooks: {} as never,
|
||||
})
|
||||
|
||||
const output = {
|
||||
title: "result",
|
||||
output: "original output",
|
||||
metadata: { sessionId: "ses_native", agent: "hephaestus" },
|
||||
}
|
||||
|
||||
// when
|
||||
await handler(
|
||||
{ tool: "hashline_edit", sessionID: "ses_parent", callID: "call_native" },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.title).toBe("stored title")
|
||||
expect(output.metadata).toEqual({ sessionId: "ses_native", agent: "hephaestus" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { consumeToolMetadata } from "../features/tool-metadata-store"
|
||||
import { recoverToolMetadata } from "../features/tool-metadata-store"
|
||||
import type { CreatedHooks } from "../create-hooks"
|
||||
import { log } from "../shared"
|
||||
import { log } from "../shared/logger"
|
||||
import { stripInvisibleAgentCharacters } from "../shared/agent-display-names"
|
||||
import type { PluginContext } from "./types"
|
||||
import { readState, writeState } from "../hooks/ralph-loop/storage"
|
||||
|
||||
const VERIFICATION_ATTEMPT_PATTERN = /<ulw_verification_attempt_id>(.*?)<\/ulw_verification_attempt_id>/i
|
||||
|
||||
@@ -37,20 +36,45 @@ export function createToolExecuteAfterHandler(args: {
|
||||
) => Promise<void> {
|
||||
const { ctx, hooks } = args
|
||||
|
||||
// OpenCode injects tool call ids into execute() context and after-hook input via undocumented runtime fields.
|
||||
// We must treat their identity as a best-effort correlation key, not a guaranteed public contract.
|
||||
|
||||
return async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
input: { tool: string; sessionID: string; callID?: string; callId?: string; call_id?: string },
|
||||
output: { title: string; output: string; metadata: Record<string, unknown> } | undefined,
|
||||
): Promise<void> => {
|
||||
if (!output) return
|
||||
|
||||
const stored = consumeToolMetadata(input.sessionID, input.callID)
|
||||
const hookInput = {
|
||||
tool: input.tool,
|
||||
sessionID: input.sessionID,
|
||||
callID: input.callID ?? input.callId ?? input.call_id ?? "",
|
||||
}
|
||||
|
||||
const nativeSessionId = getMetadataString(output.metadata, ["sessionId", "sessionID", "session_id"])
|
||||
const stored = recoverToolMetadata(input.sessionID, input)
|
||||
if (stored) {
|
||||
if (stored.title) {
|
||||
output.title = stored.title
|
||||
}
|
||||
if (stored.metadata) {
|
||||
output.metadata = { ...output.metadata, ...stored.metadata }
|
||||
if (nativeSessionId) {
|
||||
log("[tool-execute-after] Native output metadata already includes session linkage; skipping stored metadata overwrite", {
|
||||
tool: input.tool,
|
||||
sessionID: input.sessionID,
|
||||
callID: input.callID ?? input.callId ?? input.call_id,
|
||||
nativeSessionId,
|
||||
})
|
||||
} else {
|
||||
output.metadata = { ...output.metadata, ...stored.metadata }
|
||||
}
|
||||
}
|
||||
} else if (!nativeSessionId) {
|
||||
log("[tool-execute-after] Unable to recover stored metadata and no native session linkage was present", {
|
||||
tool: input.tool,
|
||||
sessionID: input.sessionID,
|
||||
callID: input.callID ?? input.callId ?? input.call_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.tool === "task") {
|
||||
@@ -59,7 +83,9 @@ export function createToolExecuteAfterHandler(args: {
|
||||
const agent = getMetadataString(output.metadata, ["agent"])
|
||||
const prompt = getMetadataString(output.metadata, ["prompt"])
|
||||
const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim()
|
||||
const loopState = directory ? readState(directory) : null
|
||||
const loopState = directory
|
||||
? (await import("../hooks/ralph-loop/storage")).readState(directory)
|
||||
: null
|
||||
const isVerificationContext =
|
||||
(agent ? stripInvisibleAgentCharacters(agent) : agent) === "oracle"
|
||||
&& !!sessionId
|
||||
@@ -83,7 +109,7 @@ export function createToolExecuteAfterHandler(args: {
|
||||
&& verificationAttemptId
|
||||
&& loopState.verification_attempt_id === verificationAttemptId
|
||||
) {
|
||||
writeState(directory, {
|
||||
;(await import("../hooks/ralph-loop/storage")).writeState(directory, {
|
||||
...loopState,
|
||||
verification_session_id: sessionId,
|
||||
})
|
||||
@@ -93,7 +119,7 @@ export function createToolExecuteAfterHandler(args: {
|
||||
verificationAttemptId,
|
||||
})
|
||||
} else if (isVerificationContext && !verificationAttemptId) {
|
||||
writeState(directory, {
|
||||
;(await import("../hooks/ralph-loop/storage")).writeState(directory, {
|
||||
...loopState,
|
||||
verification_session_id: sessionId,
|
||||
})
|
||||
@@ -108,26 +134,26 @@ export function createToolExecuteAfterHandler(args: {
|
||||
}
|
||||
|
||||
const runToolExecuteAfterHooks = async (): Promise<void> => {
|
||||
await hooks.toolOutputTruncator?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.claudeCodeHooks?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.preemptiveCompaction?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.contextWindowMonitor?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.commentChecker?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.directoryAgentsInjector?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.directoryReadmeInjector?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.rulesInjector?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.emptyTaskResponseDetector?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.agentUsageReminder?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.readImageResizer?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.webfetchRedirectGuard?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output)
|
||||
await hooks.toolOutputTruncator?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.claudeCodeHooks?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.preemptiveCompaction?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.contextWindowMonitor?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.commentChecker?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.directoryAgentsInjector?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.directoryReadmeInjector?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.rulesInjector?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.emptyTaskResponseDetector?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.agentUsageReminder?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.categorySkillReminder?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.interactiveBashSession?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.editErrorRecovery?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.delegateTaskRetry?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.atlasHook?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.taskResumeInfo?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.readImageResizer?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.webfetchRedirectGuard?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(hookInput, output)
|
||||
}
|
||||
|
||||
if (input.tool === "extract" || input.tool === "discard") {
|
||||
@@ -146,7 +172,7 @@ export function createToolExecuteAfterHandler(args: {
|
||||
log("[tool-execute-after] Failed to process extract/discard hooks", {
|
||||
tool: input.tool,
|
||||
sessionID: input.sessionID,
|
||||
callID: input.callID,
|
||||
callID: input.callID ?? input.callId ?? input.call_id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask } from "../../features/background-agent"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import type { BackgroundOutputArgs } from "./types"
|
||||
import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients"
|
||||
import { BACKGROUND_OUTPUT_DESCRIPTION } from "./constants"
|
||||
@@ -23,13 +23,6 @@ type ToolContextWithMetadata = {
|
||||
call_id?: string
|
||||
}
|
||||
|
||||
function resolveToolCallID(ctx: ToolContextWithMetadata): string | undefined {
|
||||
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
|
||||
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
|
||||
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
|
||||
return undefined
|
||||
}
|
||||
|
||||
function formatResolvedTitle(task: BackgroundTask): string {
|
||||
const label = task.agent === SISYPHUS_JUNIOR_AGENT && task.category ? task.category : task.agent
|
||||
return `${label} - ${task.description}`
|
||||
@@ -80,12 +73,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
|
||||
...(task.sessionID ? { sessionId: task.sessionID } : {}),
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
ctx.metadata?.(meta)
|
||||
|
||||
const callID = resolveToolCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, meta)
|
||||
}
|
||||
await publishToolMetadata(ctx, meta)
|
||||
|
||||
const shouldBlock = args.block === true
|
||||
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { BackgroundTaskArgs } from "./types"
|
||||
import { BACKGROUND_TASK_DESCRIPTION } from "./constants"
|
||||
import { resolveMessageContext } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { log } from "../../shared/logger"
|
||||
import { delay } from "./delay"
|
||||
import { getMessageDir } from "./message-dir"
|
||||
@@ -100,11 +100,7 @@ export function createBackgroundTask(
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
}
|
||||
ctx.metadata?.(bgMeta)
|
||||
|
||||
if (ctx.callID) {
|
||||
storeToolMetadata(ctx.sessionID, ctx.callID, bgMeta)
|
||||
}
|
||||
await publishToolMetadata(ctx, bgMeta)
|
||||
|
||||
return `Background task launched successfully.
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
|
||||
export async function executeBackgroundContinuation(
|
||||
args: DelegateTaskArgs,
|
||||
@@ -37,11 +36,7 @@ export async function executeBackgroundContinuation(
|
||||
model: task.model ? { providerID: task.model.providerID, modelID: task.model.modelID } : undefined,
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(bgContMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, bgContMeta)
|
||||
}
|
||||
await publishToolMetadata(ctx, bgContMeta)
|
||||
|
||||
return `Background task continued.
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { getTimingConfig } from "./timing"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
@@ -134,11 +133,7 @@ export async function executeBackgroundTask(
|
||||
title: args.description,
|
||||
metadata,
|
||||
}
|
||||
await ctx.metadata?.(unstableMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, unstableMeta)
|
||||
}
|
||||
await publishToolMetadata(ctx, unstableMeta)
|
||||
|
||||
const taskMetadataBlock = sessionId
|
||||
? `\n\n<task_metadata>\nsession_id: ${sessionId}\ntask_id: ${task.id}\nbackground_task_id: ${task.id}\n</task_metadata>`
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import type { ToolContextWithMetadata } from "./types"
|
||||
|
||||
describe("resolveCallID", () => {
|
||||
function makeCtx(overrides: Partial<ToolContextWithMetadata> = {}): ToolContextWithMetadata {
|
||||
return {
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_test",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("#given callID is set #then returns callID", () => {
|
||||
const ctx = makeCtx({ callID: "call_abc" })
|
||||
expect(resolveCallID(ctx)).toBe("call_abc")
|
||||
})
|
||||
|
||||
test("#given only callId is set #then returns callId", () => {
|
||||
const ctx = makeCtx({ callId: "call_def" })
|
||||
expect(resolveCallID(ctx)).toBe("call_def")
|
||||
})
|
||||
|
||||
test("#given only call_id is set #then returns call_id", () => {
|
||||
const ctx = makeCtx({ call_id: "call_ghi" })
|
||||
expect(resolveCallID(ctx)).toBe("call_ghi")
|
||||
})
|
||||
|
||||
test("#given callID and callId are both set #then prefers callID", () => {
|
||||
const ctx = makeCtx({ callID: "preferred", callId: "fallback" })
|
||||
expect(resolveCallID(ctx)).toBe("preferred")
|
||||
})
|
||||
|
||||
test("#given no call ID variants are set #then returns undefined", () => {
|
||||
const ctx = makeCtx()
|
||||
expect(resolveCallID(ctx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { ToolContextWithMetadata } from "./types"
|
||||
|
||||
export function resolveCallID(ctx: ToolContextWithMetadata): string | undefined {
|
||||
return ctx.callID ?? ctx.callId ?? ctx.call_id
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { ExecutorContext, SessionMessage } from "./executor-types"
|
||||
import { isPlanFamily } from "./constants"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
||||
import { getMessageDir } from "../../shared"
|
||||
@@ -78,11 +77,7 @@ export async function executeSyncContinuation(
|
||||
model: resumeModel,
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(syncContMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, syncContMeta)
|
||||
}
|
||||
await publishToolMetadata(ctx, syncContMeta)
|
||||
|
||||
const allowTask = isPlanFamily(resumeAgent)
|
||||
const tddEnabled = sisyphusAgentConfig?.tdd
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig } from "./types"
|
||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
@@ -130,11 +129,7 @@ export async function executeSyncTask(
|
||||
model: categoryModel ? { providerID: categoryModel.providerID, modelID: categoryModel.modelID } : undefined,
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(syncTaskMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, syncTaskMeta)
|
||||
}
|
||||
await publishToolMetadata(ctx, syncTaskMeta)
|
||||
|
||||
let effectiveCategoryModel = categoryModel
|
||||
let promptError = await deps.sendSyncPrompt(client, {
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-
|
||||
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
import { cancelUnstableAgentTask } from "./cancel-unstable-agent-task"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { formatDuration } from "./time-formatter"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
@@ -81,11 +80,7 @@ export async function executeUnstableAgentTask(
|
||||
model: categoryModel ? { providerID: categoryModel.providerID, modelID: categoryModel.modelID } : undefined,
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(bgTaskMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, bgTaskMeta)
|
||||
}
|
||||
await publishToolMetadata(ctx, bgTaskMeta)
|
||||
|
||||
const startTime = new Date()
|
||||
const timingCfg = getTimingConfig()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { applyHashlineEditsWithReport } from "./edit-operations"
|
||||
import { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
|
||||
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
|
||||
@@ -26,13 +26,6 @@ type ToolContextWithMetadata = ToolContextWithCallID & {
|
||||
metadata?: (value: unknown) => void
|
||||
}
|
||||
|
||||
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
|
||||
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
|
||||
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
|
||||
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
|
||||
return undefined
|
||||
}
|
||||
|
||||
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
|
||||
if (edits.length === 0) return false
|
||||
return edits.every((edit) => (edit.op === "append" || edit.op === "prepend") && !edit.pos)
|
||||
@@ -143,13 +136,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
||||
applyResult.noopEdits,
|
||||
applyResult.deduplicatedEdits
|
||||
)
|
||||
if (typeof metadataContext.metadata === "function") {
|
||||
metadataContext.metadata(formattedMeta)
|
||||
}
|
||||
const callID = resolveToolCallID(metadataContext)
|
||||
if (callID) {
|
||||
storeToolMetadata(context.sessionID, callID, formattedMeta)
|
||||
}
|
||||
await publishToolMetadata(metadataContext, formattedMeta)
|
||||
if (rename && rename !== filePath) {
|
||||
await Bun.write(rename, formattedContent)
|
||||
await Bun.file(filePath).delete()
|
||||
@@ -173,14 +160,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
||||
applyResult.deduplicatedEdits
|
||||
)
|
||||
|
||||
if (typeof metadataContext.metadata === "function") {
|
||||
metadataContext.metadata(meta)
|
||||
}
|
||||
|
||||
const callID = resolveToolCallID(metadataContext)
|
||||
if (callID) {
|
||||
storeToolMetadata(context.sessionID, callID, meta)
|
||||
}
|
||||
await publishToolMetadata(metadataContext, meta)
|
||||
|
||||
if (rename && rename !== filePath) {
|
||||
return `Moved ${filePath} to ${rename}`
|
||||
|
||||
Reference in New Issue
Block a user