From a1842f2de7ae25a1a01bf93a1ed82e21998dd340 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 16 Apr 2026 13:51:58 +0900 Subject: [PATCH 1/4] feat(tool-metadata): add shared metadata contract and bridge Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/tool-metadata-store/index.ts | 7 + .../tool-metadata-store/integration.test.ts | 96 ++++++++++++ .../publish-tool-metadata.test.ts | 76 +++++++++ .../publish-tool-metadata.ts | 28 ++++ .../recover-tool-metadata.test.ts | 47 ++++++ .../recover-tool-metadata.ts | 18 +++ .../resolve-tool-call-id.test.ts | 88 +++++++++++ .../resolve-tool-call-id.ts | 26 ++++ .../task-metadata-contract.test.ts | 116 ++++++++++++++ .../task-metadata-contract.ts | 144 ++++++++++++++++++ 10 files changed, 646 insertions(+) create mode 100644 src/features/tool-metadata-store/integration.test.ts create mode 100644 src/features/tool-metadata-store/publish-tool-metadata.test.ts create mode 100644 src/features/tool-metadata-store/publish-tool-metadata.ts create mode 100644 src/features/tool-metadata-store/recover-tool-metadata.test.ts create mode 100644 src/features/tool-metadata-store/recover-tool-metadata.ts create mode 100644 src/features/tool-metadata-store/resolve-tool-call-id.test.ts create mode 100644 src/features/tool-metadata-store/resolve-tool-call-id.ts create mode 100644 src/features/tool-metadata-store/task-metadata-contract.test.ts create mode 100644 src/features/tool-metadata-store/task-metadata-contract.ts diff --git a/src/features/tool-metadata-store/index.ts b/src/features/tool-metadata-store/index.ts index f9c4e28ad..cc89a9f72 100644 --- a/src/features/tool-metadata-store/index.ts +++ b/src/features/tool-metadata-store/index.ts @@ -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" diff --git a/src/features/tool-metadata-store/integration.test.ts b/src/features/tool-metadata-store/integration.test.ts new file mode 100644 index 000000000..2604a5edf --- /dev/null +++ b/src/features/tool-metadata-store/integration.test.ts @@ -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 + } + }) +}) diff --git a/src/features/tool-metadata-store/publish-tool-metadata.test.ts b/src/features/tool-metadata-store/publish-tool-metadata.test.ts new file mode 100644 index 000000000..704fe2392 --- /dev/null +++ b/src/features/tool-metadata-store/publish-tool-metadata.test.ts @@ -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() + }) +}) diff --git a/src/features/tool-metadata-store/publish-tool-metadata.ts b/src/features/tool-metadata-store/publish-tool-metadata.ts new file mode 100644 index 000000000..84e69155e --- /dev/null +++ b/src/features/tool-metadata-store/publish-tool-metadata.ts @@ -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 +} + +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 } +} diff --git a/src/features/tool-metadata-store/recover-tool-metadata.test.ts b/src/features/tool-metadata-store/recover-tool-metadata.test.ts new file mode 100644 index 000000000..e97e87eea --- /dev/null +++ b/src/features/tool-metadata-store/recover-tool-metadata.test.ts @@ -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() + }) +}) diff --git a/src/features/tool-metadata-store/recover-tool-metadata.ts b/src/features/tool-metadata-store/recover-tool-metadata.ts new file mode 100644 index 000000000..17a2278e6 --- /dev/null +++ b/src/features/tool-metadata-store/recover-tool-metadata.ts @@ -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) +} diff --git a/src/features/tool-metadata-store/resolve-tool-call-id.test.ts b/src/features/tool-metadata-store/resolve-tool-call-id.test.ts new file mode 100644 index 000000000..2ad63a516 --- /dev/null +++ b/src/features/tool-metadata-store/resolve-tool-call-id.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/features/tool-metadata-store/resolve-tool-call-id.ts b/src/features/tool-metadata-store/resolve-tool-call-id.ts new file mode 100644 index 000000000..d5879ae5f --- /dev/null +++ b/src/features/tool-metadata-store/resolve-tool-call-id.ts @@ -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 +} diff --git a/src/features/tool-metadata-store/task-metadata-contract.test.ts b/src/features/tool-metadata-store/task-metadata-contract.test.ts new file mode 100644 index 000000000..2cf2aaa7c --- /dev/null +++ b/src/features/tool-metadata-store/task-metadata-contract.test.ts @@ -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("\nsession_id: ses_abc\n") + }) + + 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( + "\nsession_id: ses_bg_123\ntask_id: bg_123\nbackground_task_id: bg_123\nsubagent: explore\ncategory: quick\n" + ) + }) +}) + +describe("parseTaskMetadataBlock", () => { + test("#given a task metadata block #when parsing #then it extracts the structured link", () => { + // given + const text = "\nsession_id: ses_sync_123\ntask_id: task_123\nbackground_task_id: bg_123\nsubagent: oracle\ncategory: deep\n" + + // 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 = "\nsession_id: ses_text_456\n" + + // 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\nsession_id: ses_text_456\nsubagent: oracle\n" + + // 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" }) + }) +}) diff --git a/src/features/tool-metadata-store/task-metadata-contract.ts b/src/features/tool-metadata-store/task-metadata-contract.ts new file mode 100644 index 000000000..4044482e5 --- /dev/null +++ b/src/features/tool-metadata-store/task-metadata-contract.ts @@ -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 { + 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 | undefined { + return readString(metadata.sessionId) ?? readString(metadata.sessionID) ?? readString(metadata.session_id) +} + +function readTaskIdFromMetadata(metadata: Record): string | undefined { + return readString(metadata.taskId) ?? readString(metadata.taskID) ?? readString(metadata.task_id) +} + +function readBackgroundTaskIdFromMetadata(metadata: Record): string | undefined { + return readString(metadata.backgroundTaskId) + ?? readString(metadata.backgroundTaskID) + ?? readString(metadata.background_task_id) +} + +function readAgentFromMetadata(metadata: Record): string | undefined { + return readString(metadata.agent) ?? readString(metadata.subagent) +} + +function readCategoryFromMetadata(metadata: Record): string | undefined { + return readString(metadata.category) +} + +function extractTaskMetadataContent(text: string): string | undefined { + const blocks = [...text.matchAll(/([\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 `\n${lines.join("\n")}\n` +} + +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 parsing") + return parsed + } + + const explicitSessionId = extractExplicitSessionId(outputText) + if (explicitSessionId) { + log("[tool-metadata-store] Falling back to explicit Session ID parsing") + return { sessionId: explicitSessionId } + } + + return {} +} From 80d3339c4c0689669c0bef38b6d4244ecf9e0f45 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 16 Apr 2026 13:52:04 +0900 Subject: [PATCH 2/4] feat(background-agent): add wait-for-task-session helper Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/index.ts | 2 + .../wait-for-task-session.test.ts | 95 +++++++++++++++++++ .../background-agent/wait-for-task-session.ts | 68 +++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 src/features/background-agent/wait-for-task-session.test.ts create mode 100644 src/features/background-agent/wait-for-task-session.ts diff --git a/src/features/background-agent/index.ts b/src/features/background-agent/index.ts index e1d1a9b73..d21b2faa8 100644 --- a/src/features/background-agent/index.ts +++ b/src/features/background-agent/index.ts @@ -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" diff --git a/src/features/background-agent/wait-for-task-session.test.ts b/src/features/background-agent/wait-for-task-session.test.ts new file mode 100644 index 000000000..812d9f700 --- /dev/null +++ b/src/features/background-agent/wait-for-task-session.test.ts @@ -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() + } + ) +}) diff --git a/src/features/background-agent/wait-for-task-session.ts b/src/features/background-agent/wait-for-task-session.ts new file mode 100644 index 000000000..eb5fe49d8 --- /dev/null +++ b/src/features/background-agent/wait-for-task-session.ts @@ -0,0 +1,68 @@ +import { getTimingConfig } from "../../tools/delegate-task/timing" +import type { BackgroundTaskStatus } from "./types" + +type SessionWaitTerminalStatus = Extract +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 { + 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 { + 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 +} From 4da300579778b93338573316ecabb70b030b174a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 16 Apr 2026 13:52:12 +0900 Subject: [PATCH 3/4] fix(plugin): harden metadata recovery and extraction Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/atlas/subagent-session-id.test.ts | 11 +++ src/hooks/atlas/subagent-session-id.ts | 22 ++--- .../oracle-verification-detector.ts | 15 +--- src/hooks/task-resume-info/hook.ts | 19 +--- src/hooks/task-resume-info/index.test.ts | 18 ++++ src/plugin/tool-execute-after.test.ts | 62 ++++++++++++- src/plugin/tool-execute-after.ts | 86 ++++++++++++------- 7 files changed, 158 insertions(+), 75 deletions(-) diff --git a/src/hooks/atlas/subagent-session-id.test.ts b/src/hooks/atlas/subagent-session-id.test.ts index b73a0c75c..3b8c68757 100644 --- a/src/hooks/atlas/subagent-session-id.test.ts +++ b/src/hooks/atlas/subagent-session-id.test.ts @@ -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" } diff --git a/src/hooks/atlas/subagent-session-id.ts b/src/hooks/atlas/subagent-session-id.ts index 95aef2aee..cc52deb88 100644 --- a/src/hooks/atlas/subagent-session-id.ts +++ b/src/hooks/atlas/subagent-session-id.ts @@ -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).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(/([\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: { diff --git a/src/hooks/ralph-loop/oracle-verification-detector.ts b/src/hooks/ralph-loop/oracle-verification-detector.ts index 9d360282d..277ef1fe2 100644 --- a/src/hooks/ralph-loop/oracle-verification-detector.ts +++ b/src/hooks/ralph-loop/oracle-verification-detector.ts @@ -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 = /[ \t]*(\S+?)[ \t]*<\/promise>/is -const TASK_METADATA_PATTERN = /[ \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 { diff --git a/src/hooks/task-resume-info/hook.ts b/src/hooks/task-resume-info/hook.ts index 1774aef6a..392fc8761 100644 --- a/src/hooks/task-resume-info/hook.ts +++ b/src/hooks/task-resume-info/hook.ts @@ -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_-]+)/, - /\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 = diff --git a/src/hooks/task-resume-info/index.test.ts b/src/hooks/task-resume-info/index.test.ts index 2d10ef757..c592bd7c6 100644 --- a/src/hooks/task-resume-info/index.test.ts +++ b/src/hooks/task-resume-info/index.test.ts @@ -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 () => { diff --git a/src/plugin/tool-execute-after.test.ts b/src/plugin/tool-execute-after.test.ts index cb70a91f8..7c8e9d87c 100644 --- a/src/plugin/tool-execute-after.test.ts +++ b/src/plugin/tool-execute-after.test.ts @@ -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" }) + }) }) diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index b0c31bb47..19bb724d7 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -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>/i @@ -37,20 +36,45 @@ export function createToolExecuteAfterHandler(args: { ) => Promise { 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 } | undefined, ): Promise => { 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 => { - 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, }) } From c6a407c4861c81366bfde695ff824cd2e3356435 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 16 Apr 2026 13:52:20 +0900 Subject: [PATCH 4/4] refactor(tools): migrate producers to shared metadata bridge Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../create-background-output.ts | 16 +------- .../background-task/create-background-task.ts | 8 +--- .../delegate-task/background-continuation.ts | 9 +---- src/tools/delegate-task/background-task.ts | 9 +---- .../delegate-task/resolve-call-id.test.ts | 40 ------------------- src/tools/delegate-task/resolve-call-id.ts | 5 --- src/tools/delegate-task/sync-continuation.ts | 9 +---- src/tools/delegate-task/sync-task.ts | 9 +---- .../delegate-task/unstable-agent-task.ts | 9 +---- .../hashline-edit/hashline-edit-executor.ts | 26 ++---------- 10 files changed, 17 insertions(+), 123 deletions(-) delete mode 100644 src/tools/delegate-task/resolve-call-id.test.ts delete mode 100644 src/tools/delegate-task/resolve-call-id.ts diff --git a/src/tools/background-task/create-background-output.ts b/src/tools/background-task/create-background-output.ts index 925db344a..7e8ac8f3d 100644 --- a/src/tools/background-task/create-background-output.ts +++ b/src/tools/background-task/create-background-output.ts @@ -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, } - 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) diff --git a/src/tools/background-task/create-background-task.ts b/src/tools/background-task/create-background-task.ts index 0d2c38f0f..cd892e6a3 100644 --- a/src/tools/background-task/create-background-task.ts +++ b/src/tools/background-task/create-background-task.ts @@ -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. diff --git a/src/tools/delegate-task/background-continuation.ts b/src/tools/delegate-task/background-continuation.ts index 0d365d964..dd0850439 100644 --- a/src/tools/delegate-task/background-continuation.ts +++ b/src/tools/delegate-task/background-continuation.ts @@ -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. diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index 184325ec9..73d43ad02 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -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\nsession_id: ${sessionId}\ntask_id: ${task.id}\nbackground_task_id: ${task.id}\n` diff --git a/src/tools/delegate-task/resolve-call-id.test.ts b/src/tools/delegate-task/resolve-call-id.test.ts deleted file mode 100644 index 7b4da140e..000000000 --- a/src/tools/delegate-task/resolve-call-id.test.ts +++ /dev/null @@ -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 { - 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() - }) -}) diff --git a/src/tools/delegate-task/resolve-call-id.ts b/src/tools/delegate-task/resolve-call-id.ts deleted file mode 100644 index cfa3b747e..000000000 --- a/src/tools/delegate-task/resolve-call-id.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ToolContextWithMetadata } from "./types" - -export function resolveCallID(ctx: ToolContextWithMetadata): string | undefined { - return ctx.callID ?? ctx.callId ?? ctx.call_id -} diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index fa6f9f022..a8e412ece 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -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 diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 18d99e500..7675fa0de 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -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, { diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index 7d10780cb..7a6d46e0d 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -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() diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index b9412d89e..54509ab6c 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -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}`