Merge branch 'dev' into fix/surface-subagent-quota-error
This commit is contained in:
@@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state";
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -112,15 +113,14 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
resetState(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { executeCompact } from "./executor"
|
||||
import type { AutoCompactState } from "./types"
|
||||
import * as recoveryStrategy from "./recovery-strategy"
|
||||
import * as messagesReader from "../session-recovery/storage/messages-reader"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
|
||||
@@ -37,7 +38,7 @@ function createFakeTimeouts(): FakeTimeouts {
|
||||
callback,
|
||||
args,
|
||||
})
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((id?: number) => {
|
||||
@@ -243,7 +244,7 @@ describe("executeCompact lock management", () => {
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig)
|
||||
|
||||
// then: Toast should be shown
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls
|
||||
const blockedToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Compact In Progress",
|
||||
)
|
||||
@@ -276,7 +277,7 @@ describe("executeCompact lock management", () => {
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig)
|
||||
|
||||
// then: Should show failure toast
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls
|
||||
const failureToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import * as originalDeduplicationRecovery from "./deduplication-recovery"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const attemptDeduplicationRecoveryMock = mock(async () => {})
|
||||
|
||||
@@ -20,7 +21,7 @@ function createImmediateTimeouts(): () => void {
|
||||
|
||||
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
|
||||
callback(...args)
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>
|
||||
return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((_: ReturnType<typeof setTimeout>) => {}) as typeof clearTimeout
|
||||
|
||||
@@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
|
||||
import { clearSessionState } from "./state"
|
||||
import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
@@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID)
|
||||
|
||||
clearSessionState(autoCompactState, sessionInfo.id)
|
||||
clearSessionState(autoCompactState, sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error })
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
dependencies.log("[auto-compact] message.updated with error", { sessionID, error: info.error })
|
||||
@@ -137,7 +138,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
|
||||
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type TimeoutCall = {
|
||||
handle: ReturnType<typeof setTimeout>
|
||||
@@ -95,7 +96,7 @@ describe("runSummarizeRetryStrategy", () => {
|
||||
//#given
|
||||
const timeoutCalls: TimeoutCall[] = []
|
||||
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
|
||||
const handle = timeoutCalls.length + 1 as unknown as ReturnType<typeof setTimeout>
|
||||
const handle = unsafeTestValue<ReturnType<typeof setTimeout>>(timeoutCalls.length + 1)
|
||||
timeoutCalls.push({ handle, delay: delay ?? 0 })
|
||||
return handle
|
||||
}) as typeof setTimeout
|
||||
@@ -132,7 +133,7 @@ describe("runSummarizeRetryStrategy", () => {
|
||||
let scheduledCallback: (() => void) | undefined
|
||||
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => {
|
||||
scheduledCallback = () => callback()
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||
return unsafeTestValue<ReturnType<typeof setTimeout>>(1)
|
||||
}) as typeof setTimeout
|
||||
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
@@ -176,7 +177,7 @@ describe("runSummarizeRetryStrategy", () => {
|
||||
autoCompactState.emptyContentAttemptBySession.set(sessionID, 3)
|
||||
autoCompactState.retryTimerBySession.set(
|
||||
sessionID,
|
||||
1 as unknown as ReturnType<typeof setTimeout>,
|
||||
unsafeTestValue<ReturnType<typeof setTimeout>>(1),
|
||||
)
|
||||
|
||||
//#when
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createAtlasHook } from "./atlas-hook"
|
||||
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, clearSessionAgent, registerAgentName, setSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
// Force process isolation in CI runner (globalThis.setTimeout override conflicts with other atlas tests)
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
@@ -79,7 +80,7 @@ describe("atlas background task retry", () => {
|
||||
callback: () => (callback as LongTimerCallback)(...args),
|
||||
cleared: false,
|
||||
})
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
|
||||
}
|
||||
|
||||
return originalSetTimeout(callback, delay, ...args)
|
||||
@@ -120,7 +121,7 @@ describe("atlas background task retry", () => {
|
||||
|
||||
let backgroundRunning = true
|
||||
const promptMock = mock(async () => ({}))
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -128,13 +129,13 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}>({
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -161,7 +162,7 @@ describe("atlas background task retry", () => {
|
||||
|
||||
let backgroundRunning = true
|
||||
const promptMock = mock(async () => ({}))
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -169,13 +170,13 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}>({
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -204,7 +205,7 @@ describe("atlas background task retry", () => {
|
||||
|
||||
let remainingRunningRetries = 2
|
||||
const promptMock = mock(async () => ({}))
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -212,9 +213,11 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
}>({
|
||||
getTasksByParentSession: () => {
|
||||
if (remainingRunningRetries > 0) {
|
||||
remainingRunningRetries -= 1
|
||||
@@ -223,9 +226,7 @@ describe("atlas background task retry", () => {
|
||||
|
||||
return []
|
||||
},
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -258,7 +259,7 @@ describe("atlas background task retry", () => {
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
let backgroundCheckCount = 0
|
||||
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -266,9 +267,11 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
}>({
|
||||
getTasksByParentSession: () => {
|
||||
backgroundCheckCount += 1
|
||||
if (backgroundCheckCount === 1) {
|
||||
@@ -281,9 +284,7 @@ describe("atlas background task retry", () => {
|
||||
|
||||
return []
|
||||
},
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -313,7 +314,7 @@ describe("atlas background task retry", () => {
|
||||
|
||||
let backgroundRunning = true
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -321,13 +322,13 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}>({
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -366,7 +367,7 @@ describe("atlas background task retry", () => {
|
||||
let backgroundRunning = true
|
||||
let descendantAgent = "atlas"
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -384,18 +385,18 @@ describe("atlas background task retry", () => {
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
}>({
|
||||
getTasksByParentSession: (currentSessionID: string) => {
|
||||
if (currentSessionID !== descendantSessionID) {
|
||||
return []
|
||||
}
|
||||
return backgroundRunning ? [{ status: "running" }] : []
|
||||
},
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -424,7 +425,7 @@ describe("atlas background task retry", () => {
|
||||
|
||||
const deferredPrompt = createDeferred<{}>()
|
||||
const promptAsyncMock = mock(() => deferredPrompt.promise)
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -432,7 +433,7 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput)
|
||||
}))
|
||||
|
||||
// when
|
||||
const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
@@ -462,7 +463,7 @@ describe("atlas background task retry", () => {
|
||||
promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise)
|
||||
promptAsyncMock.mockImplementationOnce(async () => ({}))
|
||||
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -470,13 +471,13 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
getTasksByParentSession: () => [],
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}>({
|
||||
getTasksByParentSession: () => [],
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -515,7 +516,7 @@ describe("atlas background task retry", () => {
|
||||
})
|
||||
promptAsyncMock.mockImplementationOnce(async () => ({}))
|
||||
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -523,13 +524,13 @@ describe("atlas background task retry", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput, {
|
||||
}), {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
|
||||
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
|
||||
},
|
||||
}>({
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
}),
|
||||
})
|
||||
|
||||
// when
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { registerAgentName, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("injectBoulderContinuation", () => {
|
||||
beforeEach(() => {
|
||||
@@ -20,7 +21,7 @@ describe("injectBoulderContinuation", () => {
|
||||
const promptAsyncMock = mock(async (_request: unknown) => undefined)
|
||||
const messagesMock = mock(async () => ({ data: [] }))
|
||||
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<PluginInput>({
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
@@ -28,7 +29,7 @@ describe("injectBoulderContinuation", () => {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await injectBoulderContinuation({
|
||||
@@ -60,7 +61,7 @@ describe("injectBoulderContinuation", () => {
|
||||
const messagesMock = mock(async () => ({ data: [] }))
|
||||
const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 }
|
||||
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<PluginInput>({
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
@@ -68,7 +69,7 @@ describe("injectBoulderContinuation", () => {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await injectBoulderContinuation({
|
||||
@@ -78,9 +79,9 @@ describe("injectBoulderContinuation", () => {
|
||||
remaining: 1,
|
||||
total: 2,
|
||||
agent: "atlas",
|
||||
backgroundManager: {
|
||||
backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
|
||||
getTasksByParentSession: () => [{ status: "running" }],
|
||||
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"],
|
||||
}),
|
||||
sessionState,
|
||||
})
|
||||
|
||||
@@ -98,7 +99,7 @@ describe("injectBoulderContinuation", () => {
|
||||
const messagesMock = mock(async () => ({ data: [] }))
|
||||
const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 }
|
||||
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<PluginInput>({
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
@@ -106,7 +107,7 @@ describe("injectBoulderContinuation", () => {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await injectBoulderContinuation({
|
||||
@@ -116,9 +117,9 @@ describe("injectBoulderContinuation", () => {
|
||||
remaining: 1,
|
||||
total: 2,
|
||||
agent: "atlas",
|
||||
backgroundManager: {
|
||||
backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
|
||||
getTasksByParentSession: () => [{ status: "pending" }],
|
||||
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"],
|
||||
}),
|
||||
sessionState,
|
||||
})
|
||||
|
||||
@@ -134,7 +135,7 @@ describe("injectBoulderContinuation", () => {
|
||||
const promptAsyncMock = mock(async (_request: unknown) => undefined)
|
||||
const messagesMock = mock(async () => ({ data: [] }))
|
||||
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<PluginInput>({
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
@@ -142,7 +143,7 @@ describe("injectBoulderContinuation", () => {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await injectBoulderContinuation({
|
||||
@@ -189,7 +190,7 @@ describe("injectBoulderContinuation", () => {
|
||||
}],
|
||||
}))
|
||||
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<PluginInput>({
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
@@ -197,7 +198,7 @@ describe("injectBoulderContinuation", () => {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await injectBoulderContinuation({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { isAbortError } from "./is-abort-error"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
@@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
@@ -39,7 +40,7 @@ export function createAtlasEventHandler(input: {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
|
||||
return
|
||||
@@ -47,7 +48,7 @@ export function createAtlasEventHandler(input: {
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -64,7 +65,7 @@ export function createAtlasEventHandler(input: {
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
@@ -78,7 +79,7 @@ export function createAtlasEventHandler(input: {
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
@@ -90,20 +91,20 @@ export function createAtlasEventHandler(input: {
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
const deletedState = sessions.get(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
const deletedState = sessions.get(sessionID)
|
||||
if (deletedState?.pendingRetryTimer) {
|
||||
clearTimeout(deletedState.pendingRetryTimer)
|
||||
}
|
||||
sessions.delete(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
sessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
const compactedState = sessions.get(sessionID)
|
||||
if (compactedState?.pendingRetryTimer) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
@@ -49,7 +50,7 @@ describe("atlas hook idle-event complete boulder", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
@@ -59,7 +60,7 @@ describe("atlas hook idle-event complete boulder", () => {
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
}))
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
@@ -32,7 +33,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
}
|
||||
|
||||
function createHook(parentSessionIDs?: Record<string, string | undefined>) {
|
||||
return createAtlasHook({
|
||||
return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
@@ -52,7 +53,7 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-persisted-lineage-storage-${randomUUID()}`)
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
|
||||
@@ -58,7 +59,7 @@ describe("atlas hook idle-event persisted lineage", () => {
|
||||
parentSessionIDs?: Record<string, string | undefined>,
|
||||
messagesBySession?: Record<string, Array<{ info: { agent: string; providerID: string; modelID: string } }>>,
|
||||
) {
|
||||
return createAtlasHook({
|
||||
return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
@@ -79,7 +80,7 @@ describe("atlas hook idle-event persisted lineage", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -173,7 +174,7 @@ describe("atlas hook idle-event persisted lineage", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createAtlasHook({
|
||||
const hook = createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
@@ -193,7 +194,7 @@ describe("atlas hook idle-event persisted lineage", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
}))
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createBoulderState, readBoulderState, writeBoulderState } from "../../f
|
||||
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
import type { SessionState } from "./types"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("handleAtlasSessionIdle completion nudge", () => {
|
||||
const SESSION_ID = "session-main-1"
|
||||
@@ -76,14 +77,14 @@ describe("handleAtlasSessionIdle completion nudge", () => {
|
||||
return { data: {} }
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<PluginInput>({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
const sessionStateById = new Map<string, SessionState>()
|
||||
const getState = (sessionId: string): SessionState => {
|
||||
|
||||
@@ -1347,6 +1347,38 @@ session_id: ses_untrusted_999
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should inject continuation when idle event carries session id in info", async () => {
|
||||
// given - boulder state with incomplete plan and nested session event shape
|
||||
const planPath = join(TEST_DIR, "test-plan-info-idle.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2\n- [ ] Task 3")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan-info-idle",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { info: { id: MAIN_SESSION_ID } },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should call prompt with continuation
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
|
||||
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should settle idle before injecting boulder continuation", async () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("resolveRecentPromptContextForSession", () => {
|
||||
test("uses message time.created rather than SDK array order for recent prompt context", async () => {
|
||||
// given
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<PluginInput>({
|
||||
client: {
|
||||
session: {
|
||||
messages: mock(async () => ({
|
||||
@@ -32,7 +33,7 @@ describe("resolveRecentPromptContextForSession", () => {
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveRecentPromptContextForSession(ctx, "ses_123")
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Project } from "@opencode-ai/sdk"
|
||||
import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const isCallerOrchestratorMock = mock(async () => true)
|
||||
const collectGitDiffStatsMock = mock(() => ({
|
||||
@@ -80,11 +81,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
|
||||
function createHandler(parentSessionIDs?: Record<string, string | undefined>) {
|
||||
const project = createProject()
|
||||
const client = {
|
||||
const client = unsafeTestValue<PluginInput["client"]>({
|
||||
session: {
|
||||
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
})
|
||||
|
||||
if (parentSessionIDs) {
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
@@ -141,11 +142,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
const childSessionID = "ses_child123"
|
||||
const planPath = join(testDirectory, "background-launch-plan.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
const client = unsafeTestValue<PluginInput["client"]>({
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
})
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
|
||||
@@ -215,11 +216,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
const childSessionID = "ses_child_lookup_failure"
|
||||
const planPath = join(testDirectory, "background-launch-plan.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
const client = unsafeTestValue<PluginInput["client"]>({
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
})
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => {
|
||||
if (input?.path?.id === childSessionID) {
|
||||
@@ -288,11 +289,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
const childSessionID = "ses_outside_lineage"
|
||||
const planPath = join(testDirectory, "background-launch-plan.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
const client = unsafeTestValue<PluginInput["client"]>({
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
})
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(input?.path?.id === childSessionID ? "ses_unrelated_parent" : undefined),
|
||||
@@ -358,11 +359,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
const childSessionID = "ses_unrelated_child"
|
||||
const planPath = join(testDirectory, "background-launch-plan.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
const client = unsafeTestValue<PluginInput["client"]>({
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
})
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
|
||||
@@ -431,11 +432,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
const planPathA = join(testDirectory, "background-launch-work-a.md")
|
||||
const planPathB = join(testDirectory, "background-launch-work-b.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
const client = unsafeTestValue<PluginInput["client"]>({
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
})
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "./detector"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
@@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function getDeletedSessionID(properties: unknown): string | null {
|
||||
if (!isRecord(properties)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = properties.info
|
||||
if (!isRecord(info)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof info.id === "string" ? info.id : null
|
||||
return resolveSessionEventID(properties) ?? null
|
||||
}
|
||||
|
||||
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
|
||||
@@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string |
|
||||
"commandId",
|
||||
]
|
||||
|
||||
const recordInput = input as unknown
|
||||
const recordInput: unknown = input
|
||||
if (!isRecord(recordInput)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ mock.module("../constants", () => ({
|
||||
const current = mockState.candidates
|
||||
// Forward array methods/properties to the mutable candidates list
|
||||
// so getCachedVersion's `for (... of ...)` sees fresh data per test.
|
||||
const value = (current as unknown as Record<PropertyKey, unknown>)[prop]
|
||||
const value = (unsafeTestValue<Record<PropertyKey, unknown>>(current))[prop]
|
||||
if (typeof value === "function") {
|
||||
return (value as (...args: unknown[]) => unknown).bind(current)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ mock.module("./package-json-locator", () => ({
|
||||
}))
|
||||
|
||||
import { getCachedVersion } from "./cached-version"
|
||||
import { unsafeTestValue } from "../../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("getCachedVersion (GH-3257)", () => {
|
||||
let cacheRoot: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { buildReminderMessage } from "./formatter"
|
||||
|
||||
/**
|
||||
@@ -120,15 +121,14 @@ export function createCategorySkillReminderHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionStates.delete(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
sessionStates.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
sessionStates.delete(sessionID)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createCategorySkillReminderHook } from "./index"
|
||||
import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import * as sharedModule from "../../shared"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("category-skill-reminder hook", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
@@ -21,13 +22,13 @@ describe("category-skill-reminder hook", () => {
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
return unsafeTestValue({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
function createHook(availableSkills: AvailableSkill[] = []) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "path"
|
||||
import type { ClaudeHookEvent } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getOpenCodeConfigDir } from "../../shared"
|
||||
import { bunFile } from "../../shared/bun-file-shim"
|
||||
|
||||
const CONFIG_CACHE_TTL_MS = 30_000
|
||||
|
||||
@@ -61,7 +62,7 @@ async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig |
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(path).text()
|
||||
const content = await bunFile(path).text()
|
||||
return JSON.parse(content) as PluginExtendedConfig
|
||||
} catch (error) {
|
||||
log("Failed to load config", { path, error })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import { bunFile } from "../../shared/bun-file-shim"
|
||||
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
|
||||
|
||||
const CONFIG_CACHE_TTL_MS = 30_000
|
||||
@@ -126,7 +127,7 @@ export async function loadClaudeHooksConfig(
|
||||
for (const settingsPath of paths) {
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = await Bun.file(settingsPath).text()
|
||||
const content = await bunFile(settingsPath).text()
|
||||
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
|
||||
if (settings.hooks) {
|
||||
const normalizedHooks = normalizeHooksConfig(settings.hooks)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
||||
import type { HookHttp } from "./types"
|
||||
import * as sharedModule from "../../shared"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const mockFetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
||||
@@ -31,7 +32,7 @@ describe("executeHttpHook TLS security", () => {
|
||||
let logCalls: Array<{ message: string; data?: unknown }>
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch
|
||||
globalThis.fetch = unsafeTestValue<typeof fetch>(mockFetch)
|
||||
mockFetch.mockReset()
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
||||
import type { HookHttp } from "./types"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const mockFetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
||||
@@ -9,7 +10,7 @@ const originalFetch = globalThis.fetch
|
||||
|
||||
describe("executeHttpHook", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch
|
||||
globalThis.fetch = unsafeTestValue<typeof fetch>(mockFetch)
|
||||
mockFetch.mockReset()
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
||||
@@ -33,7 +34,7 @@ describe("executeHttpHook", () => {
|
||||
await executeHttpHook(hook, stdinData)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const [url, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
|
||||
expect(url).toBe("http://localhost:8080/hooks/pre-tool-use")
|
||||
expect(options.method).toBe("POST")
|
||||
expect(options.body).toBe(stdinData)
|
||||
@@ -44,7 +45,7 @@ describe("executeHttpHook", () => {
|
||||
|
||||
await executeHttpHook(hook, stdinData)
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
|
||||
const headers = options.headers as Record<string, string>
|
||||
expect(headers["Content-Type"]).toBe("application/json")
|
||||
})
|
||||
@@ -72,7 +73,7 @@ describe("executeHttpHook", () => {
|
||||
|
||||
await executeHttpHook(hook, "{}")
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
|
||||
const headers = options.headers as Record<string, string>
|
||||
expect(headers["Authorization"]).toBe("Bearer secret-123")
|
||||
})
|
||||
@@ -88,7 +89,7 @@ describe("executeHttpHook", () => {
|
||||
|
||||
await executeHttpHook(hook, "{}")
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
|
||||
const headers = options.headers as Record<string, string>
|
||||
expect(headers["Authorization"]).toBe("Bearer secret-123")
|
||||
})
|
||||
@@ -104,7 +105,7 @@ describe("executeHttpHook", () => {
|
||||
|
||||
await executeHttpHook(hook, "{}")
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
|
||||
const headers = options.headers as Record<string, string>
|
||||
expect(headers["Authorization"]).toBe("Bearer ")
|
||||
})
|
||||
@@ -121,7 +122,7 @@ describe("executeHttpHook", () => {
|
||||
|
||||
await executeHttpHook(hook, "{}")
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
|
||||
expect(options.signal).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clearTranscriptCache } from "../transcript"
|
||||
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
|
||||
import type { PluginConfig } from "../types"
|
||||
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
||||
import { resolveSessionEventID } from "../../../shared/event-session-id"
|
||||
import {
|
||||
clearAllSessionHookState,
|
||||
clearSessionHookState,
|
||||
@@ -26,7 +27,7 @@ export function createSessionEventHandler(
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
sessionErrorState.set(sessionID, {
|
||||
hasError: true,
|
||||
@@ -38,13 +39,13 @@ export function createSessionEventHandler(
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
parentSessionIdCache.delete(sessionInfo.id)
|
||||
clearTranscriptCache(sessionInfo.id)
|
||||
clearToolInputCache(sessionInfo.id)
|
||||
contextCollector?.clear(sessionInfo.id)
|
||||
clearSessionHookState(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
parentSessionIdCache.delete(sessionID)
|
||||
clearTranscriptCache(sessionID)
|
||||
clearToolInputCache(sessionID)
|
||||
contextCollector?.clear(sessionID)
|
||||
clearSessionHookState(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -54,7 +55,7 @@ export function createSessionEventHandler(
|
||||
}
|
||||
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("tool-input-cache", () => {
|
||||
const originalSetInterval = globalThis.setInterval
|
||||
@@ -33,11 +34,11 @@ describe("tool-input-cache", () => {
|
||||
|
||||
test("#given cleanup timer started #when stop cleanup runs #then interval is cleared and cache is emptied", async () => {
|
||||
//#given
|
||||
const intervalHandle = { unref: mock(() => {}) } as unknown as ReturnType<typeof setInterval>
|
||||
const intervalHandle = unsafeTestValue<ReturnType<typeof setInterval>>({ unref: mock(() => {}) })
|
||||
const setIntervalMock = mock(() => intervalHandle)
|
||||
const clearIntervalMock = mock(() => {})
|
||||
globalThis.setInterval = setIntervalMock as unknown as typeof setInterval
|
||||
globalThis.clearInterval = clearIntervalMock as unknown as typeof clearInterval
|
||||
globalThis.setInterval = unsafeTestValue<typeof setInterval>(setIntervalMock)
|
||||
globalThis.clearInterval = unsafeTestValue<typeof clearInterval>(clearIntervalMock)
|
||||
|
||||
const modulePath = new URL("./tool-input-cache.ts", import.meta.url).pathname
|
||||
const cacheModule = await import(`${modulePath}?stop-clear`)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os"
|
||||
|
||||
import { processWithCli } from "./cli-runner"
|
||||
import type { PendingCall } from "./types"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
function createMockInput() {
|
||||
return {
|
||||
@@ -74,7 +75,7 @@ done
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
|
||||
fn()
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>
|
||||
return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
|
||||
}) as typeof setTimeout
|
||||
|
||||
try {
|
||||
@@ -102,7 +103,7 @@ done
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
|
||||
fn()
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>
|
||||
return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
|
||||
}) as typeof setTimeout
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("pending-calls cleanup interval", () => {
|
||||
test("starts cleanup once and unrefs timer", async () => {
|
||||
@@ -7,18 +8,18 @@ describe("pending-calls cleanup interval", () => {
|
||||
const setIntervalCalls: number[] = []
|
||||
let unrefCalled = 0
|
||||
|
||||
globalThis.setInterval = ((
|
||||
globalThis.setInterval = unsafeTestValue<typeof setInterval>(((
|
||||
_handler: TimerHandler,
|
||||
timeout?: number,
|
||||
..._args: any[]
|
||||
..._args: unknown[]
|
||||
) => {
|
||||
setIntervalCalls.push(timeout as number)
|
||||
return {
|
||||
return unsafeTestValue<ReturnType<typeof setInterval>>({
|
||||
unref: () => {
|
||||
unrefCalled += 1
|
||||
},
|
||||
} as unknown as ReturnType<typeof setInterval>
|
||||
}) as unknown as typeof setInterval
|
||||
})
|
||||
}))
|
||||
|
||||
try {
|
||||
const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname
|
||||
@@ -43,20 +44,20 @@ describe("pending-calls cleanup interval", () => {
|
||||
let intervalHandle: ReturnType<typeof setInterval> | undefined
|
||||
let clearCalls = 0
|
||||
|
||||
globalThis.setInterval = ((
|
||||
globalThis.setInterval = unsafeTestValue<typeof setInterval>(((
|
||||
_handler: TimerHandler,
|
||||
_timeout?: number,
|
||||
..._args: any[]
|
||||
..._args: unknown[]
|
||||
) => {
|
||||
intervalHandle = { unref: () => {} } as unknown as ReturnType<typeof setInterval>
|
||||
intervalHandle = unsafeTestValue<ReturnType<typeof setInterval>>({ unref: () => {} })
|
||||
return intervalHandle
|
||||
}) as unknown as typeof setInterval
|
||||
}))
|
||||
|
||||
globalThis.clearInterval = ((handle?: ReturnType<typeof setInterval>) => {
|
||||
globalThis.clearInterval = unsafeTestValue<typeof clearInterval>(((handle?: ReturnType<typeof setInterval>) => {
|
||||
if (handle === intervalHandle) {
|
||||
clearCalls += 1
|
||||
}
|
||||
}) as unknown as typeof clearInterval
|
||||
}))
|
||||
|
||||
try {
|
||||
const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
clearCompactionAgentConfigCheckpoint,
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
|
||||
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
|
||||
@@ -121,14 +122,15 @@ export function createCompactionContextInjector(options?: {
|
||||
sessionID?: string
|
||||
} | undefined
|
||||
|
||||
if (!info?.sessionID || info.role !== "assistant" || !info.id) {
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID || info?.role !== "assistant" || !info.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const tailState = getTailState(info.sessionID)
|
||||
const tailState = getTailState(sessionID)
|
||||
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
|
||||
finalizeTrackedAssistantMessage(tailState)
|
||||
await maybeWarnAboutNoTextTail(info.sessionID)
|
||||
await maybeWarnAboutNoTextTail(sessionID)
|
||||
}
|
||||
|
||||
if (tailState.currentMessageID !== info.id) {
|
||||
@@ -139,7 +141,7 @@ export function createCompactionContextInjector(options?: {
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const messageID = props?.messageID as string | undefined
|
||||
const field = props?.field as string | undefined
|
||||
const delta = props?.delta as string | undefined
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
return resolveSessionEventID(props)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface TodoSnapshot {
|
||||
@@ -97,8 +98,7 @@ async function resolveTodoWriter(): Promise<TodoWriter | null> {
|
||||
}
|
||||
|
||||
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
return resolveSessionEventID(props)
|
||||
}
|
||||
|
||||
export interface CompactionTodoPreserver {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ContextLimitModelCacheState,
|
||||
} from "../shared/context-limit-resolver"
|
||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
@@ -86,10 +87,10 @@ export function createContextWindowMonitorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
remindedSessions.delete(sessionInfo.id)
|
||||
tokenCache.delete(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
remindedSessions.delete(sessionID)
|
||||
tokenCache.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +107,10 @@ export function createContextWindowMonitorHook(
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish) return
|
||||
if (isCompactionAgent(info.agent)) return
|
||||
if (!info.sessionID || !info.providerID || !info.tokens) return
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID || !info.providerID || !info.tokens) return
|
||||
|
||||
tokenCache.set(info.sessionID, {
|
||||
tokenCache.set(sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
import { processFilePathForAgentsInjection } from "./injector";
|
||||
import { clearInjectedPaths } from "./storage";
|
||||
|
||||
@@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
import { processFilePathForReadmeInjection } from "./injector";
|
||||
import { clearInjectedPaths } from "./storage";
|
||||
|
||||
@@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("createEditErrorRecoveryHook", () => {
|
||||
let hook: ReturnType<typeof createEditErrorRecoveryHook>
|
||||
|
||||
beforeEach(() => {
|
||||
hook = createEditErrorRecoveryHook({} as any)
|
||||
hook = createEditErrorRecoveryHook(unsafeTestValue({}))
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
@@ -108,7 +109,7 @@ describe("createEditErrorRecoveryHook", () => {
|
||||
const input = createInput("Edit")
|
||||
const output = {
|
||||
title: "Edit",
|
||||
output: undefined as unknown as string,
|
||||
output: unsafeTestValue<string>(undefined),
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log } from "../../shared"
|
||||
import { bunFile } from "../../shared/bun-file-shim"
|
||||
import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
|
||||
|
||||
interface HashlineEditDiffEnhancerConfig {
|
||||
@@ -38,7 +39,7 @@ function extractFilePath(args: Record<string, unknown>): string | undefined {
|
||||
|
||||
async function captureOldContent(filePath: string): Promise<string> {
|
||||
try {
|
||||
const file = Bun.file(filePath)
|
||||
const file = bunFile(filePath)
|
||||
if (await file.exists()) {
|
||||
return await file.text()
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
|
||||
|
||||
let newContent: string
|
||||
try {
|
||||
newContent = await Bun.file(filePath).text()
|
||||
newContent = await bunFile(filePath).text()
|
||||
} catch {
|
||||
log("[hashline-edit-diff-enhancer] failed to read new content", { filePath })
|
||||
return
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { bunFile } from "../../shared/bun-file-shim"
|
||||
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
|
||||
|
||||
const WRITE_SUCCESS_MARKER = "File written successfully."
|
||||
@@ -178,7 +179,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
|
||||
return
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
const file = bunFile(filePath)
|
||||
if (!(await file.exists())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types";
|
||||
import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser";
|
||||
import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager";
|
||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -106,8 +107,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
const sessionID = sessionInfo?.id;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
|
||||
if (sessionID) {
|
||||
const state = getOrCreateStateLocal(sessionID);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type StartLoopCall = {
|
||||
sessionID: string
|
||||
@@ -11,13 +12,13 @@ type StartLoopCall = {
|
||||
type CancelLoopCall = { sessionID: string }
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
return unsafeTestValue({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
function createMockRalphLoop(startLoopCalls: StartLoopCall[], cancelLoopCalls: CancelLoopCall[] = []) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import * as sharedModule from "../../shared"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("keyword-detector hyperplan-ultrawork combo", () => {
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
@@ -22,7 +23,7 @@ describe("keyword-detector hyperplan-ultrawork combo", () => {
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
return unsafeTestValue<PluginInput>({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
@@ -30,7 +31,7 @@ describe("keyword-detector hyperplan-ultrawork combo", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
}
|
||||
|
||||
test("should inject combo message when user types 'hpp ulw' (forward order)", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setMainSession, updateSessionAgent, clearSessionAgent, _resetForTesting
|
||||
import { ContextCollector } from "../../features/context-injector"
|
||||
import * as sharedModule from "../../shared"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type ToastOptions = { body: { title: string } }
|
||||
|
||||
@@ -881,13 +882,13 @@ describe("keyword-detector team mode", () => {
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
return unsafeTestValue<PluginInput>({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
}
|
||||
|
||||
test("should inject team-mode message when user types 'team mode'", async () => {
|
||||
@@ -1063,7 +1064,7 @@ describe("keyword-detector disabled_keywords config", () => {
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
return unsafeTestValue<PluginInput>({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
@@ -1071,7 +1072,7 @@ describe("keyword-detector disabled_keywords config", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
}
|
||||
|
||||
test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type StartLoopCall = {
|
||||
sessionID: string
|
||||
@@ -11,7 +12,7 @@ type StartLoopCall = {
|
||||
}
|
||||
|
||||
function createMockPluginInput(toastCalls: string[] = []) {
|
||||
return {
|
||||
return unsafeTestValue<PluginInput>({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
@@ -19,7 +20,7 @@ function createMockPluginInput(toastCalls: string[] = []) {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
}
|
||||
|
||||
function createMockRalphLoop(startLoopCalls: StartLoopCall[]) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
function createMockPluginInput(toastMessages: string[]) {
|
||||
return {
|
||||
return unsafeTestValue({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { message: string } }) => {
|
||||
@@ -11,7 +12,7 @@ function createMockPluginInput(toastMessages: string[]) {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
describe("keyword-detector ultrawork runtime variant gating", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
declare const require: (name: string) => any
|
||||
const { beforeEach, describe, expect, mock, test, afterAll } = require("bun:test")
|
||||
|
||||
@@ -86,12 +87,12 @@ describe("model fallback hook", () => {
|
||||
})
|
||||
|
||||
test("applies pending fallback on chat.message by overriding model", async () => {
|
||||
const hook = modelFallback as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(modelFallback)
|
||||
|
||||
const set = setPendingModelFallback(
|
||||
modelFallback,
|
||||
@@ -122,12 +123,12 @@ describe("model fallback hook", () => {
|
||||
})
|
||||
|
||||
test("preserves fallback progression across repeated session.error retries", async () => {
|
||||
const hook = modelFallback as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(modelFallback)
|
||||
const sessionID = "ses_model_fallback_main"
|
||||
|
||||
expect(
|
||||
@@ -212,12 +213,12 @@ describe("model fallback hook", () => {
|
||||
const sessionID = "ses_model_fallback_noop_skip"
|
||||
clearPendingModelFallback(modelFallback, sessionID)
|
||||
|
||||
const hook = modelFallback as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(modelFallback)
|
||||
|
||||
setSessionFallbackChain(modelFallback, sessionID, [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||
@@ -254,12 +255,12 @@ describe("model fallback hook", () => {
|
||||
const sessionID = "ses_model_fallback_noop_variant_skip"
|
||||
clearPendingModelFallback(modelFallback, sessionID)
|
||||
|
||||
const hook = modelFallback as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(modelFallback)
|
||||
|
||||
setSessionFallbackChain(modelFallback, sessionID, [
|
||||
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
|
||||
@@ -299,12 +300,12 @@ describe("model fallback hook", () => {
|
||||
clearPendingModelFallback(modelFallback, sessionID)
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
||||
|
||||
const hook = modelFallback as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(modelFallback)
|
||||
|
||||
setSessionFallbackChain(modelFallback, sessionID, [
|
||||
{ providers: ["provider-y"], model: "fallback-model" },
|
||||
@@ -355,16 +356,16 @@ describe("model fallback hook", () => {
|
||||
|
||||
test("shows toast when fallback is applied", async () => {
|
||||
const toastCalls: Array<{ title: string; message: string }> = []
|
||||
const hook = createModelFallbackHook({
|
||||
toast: async ({ title, message }) => {
|
||||
toastCalls.push({ title, message })
|
||||
},
|
||||
}) as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(createModelFallbackHook({
|
||||
toast: async ({ title, message }) => {
|
||||
toastCalls.push({ title, message })
|
||||
},
|
||||
}))
|
||||
|
||||
const set = setPendingModelFallback(
|
||||
hook,
|
||||
@@ -393,12 +394,12 @@ describe("model fallback hook", () => {
|
||||
const sessionID = "ses_model_fallback_ghcp"
|
||||
clearPendingModelFallback(modelFallback, sessionID)
|
||||
|
||||
const hook = modelFallback as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(modelFallback)
|
||||
|
||||
setSessionFallbackChain(modelFallback, sessionID, [
|
||||
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
|
||||
@@ -434,12 +435,12 @@ describe("model fallback hook", () => {
|
||||
const sessionID = "ses_model_fallback_google"
|
||||
clearPendingModelFallback(modelFallback, sessionID)
|
||||
|
||||
const hook = modelFallback as unknown as {
|
||||
const hook = unsafeTestValue<{
|
||||
"chat.message"?: (
|
||||
input: { sessionID: string },
|
||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||
) => Promise<void>
|
||||
}
|
||||
}>(modelFallback)
|
||||
|
||||
setSessionFallbackChain(modelFallback, sessionID, [
|
||||
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, spyOn, test } from "bun:test"
|
||||
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { createNoHephaestusNonGptHook } from "./index"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus")
|
||||
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
|
||||
@@ -19,9 +20,9 @@ describe("no-hephaestus-non-gpt hook", () => {
|
||||
test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => {
|
||||
// given - hephaestus with claude model
|
||||
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||
const hook = createNoHephaestusNonGptHook({
|
||||
const hook = createNoHephaestusNonGptHook(unsafeTestValue({
|
||||
client: { tui: { showToast } },
|
||||
} as any)
|
||||
}))
|
||||
|
||||
const output1 = createOutput()
|
||||
const output2 = createOutput()
|
||||
@@ -54,9 +55,9 @@ describe("no-hephaestus-non-gpt hook", () => {
|
||||
test("shows warning and does not switch agent when allow_non_gpt_model is enabled", async () => {
|
||||
// given - hephaestus with claude model and opt-out enabled
|
||||
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||
const hook = createNoHephaestusNonGptHook({
|
||||
const hook = createNoHephaestusNonGptHook(unsafeTestValue({
|
||||
client: { tui: { showToast } },
|
||||
} as any, {
|
||||
}), {
|
||||
allowNonGptModel: true,
|
||||
})
|
||||
|
||||
@@ -83,9 +84,9 @@ describe("no-hephaestus-non-gpt hook", () => {
|
||||
test("does not show toast when hephaestus uses gpt model", async () => {
|
||||
// given - hephaestus with gpt model
|
||||
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||
const hook = createNoHephaestusNonGptHook({
|
||||
const hook = createNoHephaestusNonGptHook(unsafeTestValue({
|
||||
client: { tui: { showToast } },
|
||||
} as any)
|
||||
}))
|
||||
|
||||
const output = createOutput()
|
||||
|
||||
@@ -104,9 +105,9 @@ describe("no-hephaestus-non-gpt hook", () => {
|
||||
test("does not show toast for non-hephaestus agent", async () => {
|
||||
// given - sisyphus with claude model (non-gpt)
|
||||
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||
const hook = createNoHephaestusNonGptHook({
|
||||
const hook = createNoHephaestusNonGptHook(unsafeTestValue({
|
||||
client: { tui: { showToast } },
|
||||
} as any)
|
||||
}))
|
||||
|
||||
const output = createOutput()
|
||||
|
||||
@@ -127,9 +128,9 @@ describe("no-hephaestus-non-gpt hook", () => {
|
||||
_resetForTesting()
|
||||
updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY)
|
||||
const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn")
|
||||
const hook = createNoHephaestusNonGptHook({
|
||||
const hook = createNoHephaestusNonGptHook(unsafeTestValue({
|
||||
client: { tui: { showToast } },
|
||||
} as any)
|
||||
}))
|
||||
|
||||
const output = createOutput()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { createNoSisyphusGptHook } from "./index"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus")
|
||||
const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus")
|
||||
@@ -22,9 +23,9 @@ function createOutput(): HookOutput {
|
||||
}
|
||||
|
||||
function createHookContext(showToast: (input: unknown) => Promise<unknown>): PluginInput {
|
||||
return {
|
||||
return unsafeTestValue<PluginInput>({
|
||||
client: { tui: { showToast } },
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
}
|
||||
|
||||
describe("no-sisyphus-gpt hook", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
|
||||
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
|
||||
|
||||
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
||||
@@ -48,7 +49,7 @@ export function createPreemptiveCompactionHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = (props?.info as { id?: string } | undefined)?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
compactionInProgress.delete(sessionID)
|
||||
compactedSessions.delete(sessionID)
|
||||
@@ -60,8 +61,7 @@ export function createPreemptiveCompactionHook(
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID as string | undefined)
|
||||
?? (props?.info as { id?: string } | undefined)?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
postCompactionMonitor.onSessionCompacted(sessionID)
|
||||
}
|
||||
@@ -81,20 +81,21 @@ export function createPreemptiveCompactionHook(
|
||||
parts?: unknown
|
||||
} | undefined
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!info || info.role !== "assistant" || !info.finish || !sessionID) return
|
||||
if (isCompactionAgent(info.agent)) return
|
||||
|
||||
if (info.providerID && info.tokens) {
|
||||
tokenCache.set(info.sessionID, {
|
||||
tokenCache.set(sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
})
|
||||
}
|
||||
compactedSessions.delete(info.sessionID)
|
||||
compactedSessions.delete(sessionID)
|
||||
|
||||
await postCompactionMonitor.onAssistantMessageUpdated({
|
||||
sessionID: info.sessionID,
|
||||
sessionID,
|
||||
id: info.id,
|
||||
parts: info.parts,
|
||||
})
|
||||
|
||||
@@ -41,6 +41,10 @@ function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs
|
||||
};
|
||||
}
|
||||
|
||||
function hasQuestions(args: Record<string, unknown>): args is Record<string, unknown> & AskUserQuestionArgs {
|
||||
return Array.isArray(args.questions);
|
||||
}
|
||||
|
||||
export function createQuestionLabelTruncatorHook() {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
@@ -50,10 +54,8 @@ export function createQuestionLabelTruncatorHook() {
|
||||
const toolName = input.tool?.toLowerCase();
|
||||
|
||||
if (toolName === "askuserquestion" || toolName === "ask_user_question") {
|
||||
const args = output.args as unknown as AskUserQuestionArgs | undefined;
|
||||
|
||||
if (args?.questions) {
|
||||
const truncatedArgs = truncateQuestionLabels(args);
|
||||
if (hasQuestions(output.args)) {
|
||||
const truncatedArgs = truncateQuestionLabels(output.args);
|
||||
Object.assign(output.args, truncatedArgs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { createQuestionLabelTruncatorHook } from "./index";
|
||||
|
||||
@@ -23,10 +24,10 @@ describe("createQuestionLabelTruncatorHook", () => {
|
||||
};
|
||||
|
||||
// when
|
||||
await hook["tool.execute.before"]?.(input as any, output as any);
|
||||
await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output));
|
||||
|
||||
// then
|
||||
const truncatedLabel = (output.args as any).questions[0].options[0].label;
|
||||
const truncatedLabel = (unsafeTestValue(output.args)).questions[0].options[0].label;
|
||||
expect(truncatedLabel.length).toBeLessThanOrEqual(30);
|
||||
expect(truncatedLabel).toBe("This is a very long label t...");
|
||||
expect(truncatedLabel.endsWith("...")).toBe(true);
|
||||
@@ -50,10 +51,10 @@ describe("createQuestionLabelTruncatorHook", () => {
|
||||
};
|
||||
|
||||
// when
|
||||
await hook["tool.execute.before"]?.(input as any, output as any);
|
||||
await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output));
|
||||
|
||||
// then
|
||||
const resultLabel = (output.args as any).questions[0].options[0].label;
|
||||
const resultLabel = (unsafeTestValue(output.args)).questions[0].options[0].label;
|
||||
expect(resultLabel).toBe(shortLabel);
|
||||
});
|
||||
|
||||
@@ -74,10 +75,10 @@ describe("createQuestionLabelTruncatorHook", () => {
|
||||
};
|
||||
|
||||
// when
|
||||
await hook["tool.execute.before"]?.(input as any, output as any);
|
||||
await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output));
|
||||
|
||||
// then
|
||||
const resultLabel = (output.args as any).questions[0].options[0].label;
|
||||
const resultLabel = (unsafeTestValue(output.args)).questions[0].options[0].label;
|
||||
expect(resultLabel).toBe(exactLabel);
|
||||
});
|
||||
|
||||
@@ -90,7 +91,7 @@ describe("createQuestionLabelTruncatorHook", () => {
|
||||
const originalArgs = { ...output.args };
|
||||
|
||||
// when
|
||||
await hook["tool.execute.before"]?.(input as any, output as any);
|
||||
await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output));
|
||||
|
||||
// then
|
||||
expect(output.args).toEqual(originalArgs);
|
||||
@@ -120,11 +121,11 @@ describe("createQuestionLabelTruncatorHook", () => {
|
||||
};
|
||||
|
||||
// when
|
||||
await hook["tool.execute.before"]?.(input as any, output as any);
|
||||
await hook["tool.execute.before"]?.(unsafeTestValue(input), unsafeTestValue(output));
|
||||
|
||||
// then
|
||||
const q1opts = (output.args as any).questions[0].options;
|
||||
const q2opts = (output.args as any).questions[1].options;
|
||||
const q1opts = (unsafeTestValue(output.args)).questions[0].options;
|
||||
const q2opts = (unsafeTestValue(output.args)).questions[1].options;
|
||||
|
||||
expect(q1opts[0].label).toBe("Very long label number one ...");
|
||||
expect(q1opts[0].label.length).toBeLessThanOrEqual(30);
|
||||
|
||||
@@ -10,6 +10,16 @@ type LoopStateController = {
|
||||
markVerificationPending: (sessionID: string) => RalphLoopState | null
|
||||
}
|
||||
|
||||
function showToastBestEffort(
|
||||
ctx: PluginInput,
|
||||
body: { title: string; message: string; variant: "error" | "info" | "success"; duration: number },
|
||||
): void {
|
||||
try {
|
||||
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDetectedCompletion(
|
||||
ctx: PluginInput,
|
||||
input: {
|
||||
@@ -35,21 +45,33 @@ export async function handleDetectedCompletion(
|
||||
return
|
||||
}
|
||||
|
||||
await injectContinuationPrompt(ctx, {
|
||||
const promptResult = await injectContinuationPrompt(ctx, {
|
||||
sessionID,
|
||||
prompt: buildContinuationPrompt(verificationState),
|
||||
directory,
|
||||
apiTimeoutMs,
|
||||
})
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: {
|
||||
title: "ULTRAWORK LOOP",
|
||||
message: "DONE detected. Oracle verification is now required.",
|
||||
variant: "info",
|
||||
if (promptResult.status === "rejected") {
|
||||
log(`[${HOOK_NAME}] Failed to inject ultrawork verification prompt`, {
|
||||
sessionID,
|
||||
error: String(promptResult.error),
|
||||
})
|
||||
loopState.clear()
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Failed",
|
||||
message: `Verification dispatch rejected: ${String(promptResult.error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showToastBestEffort(ctx, {
|
||||
title: "ULTRAWORK LOOP",
|
||||
message: "DONE detected. Oracle verification is now required.",
|
||||
variant: "info",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,7 +81,5 @@ export async function handleDetectedCompletion(
|
||||
const message = state.ultrawork
|
||||
? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)`
|
||||
: `Task completed after ${state.iteration} iteration(s)`
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: { title, message, variant: "success", duration: 5000 },
|
||||
}).catch(() => {})
|
||||
showToastBestEffort(ctx, { title, message, variant: "success", duration: 5000 })
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
/// <reference types="bun-types" />
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
export type SessionMessage = {
|
||||
info?: { role?: string }
|
||||
@@ -16,8 +17,8 @@ export function createPluginInput(messages: SessionMessage[]): PluginInput {
|
||||
$: {} as PluginInput["$"],
|
||||
} as PluginInput
|
||||
|
||||
pluginInput.client.session.messages =
|
||||
(async () => ({ data: messages })) as unknown as PluginInput["client"]["session"]["messages"]
|
||||
const messagesFunction = unsafeTestValue<PluginInput["client"]["session"]["messages"]>(async () => ({ data: messages }))
|
||||
pluginInput.client.session.messages = messagesFunction
|
||||
|
||||
return pluginInput
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { detectCompletionInSessionMessages } from "./completion-promise-detector"
|
||||
import { createPluginInput } from "./completion-promise-detector-test-input"
|
||||
import { createPluginInput } from "./completion-promise-detector-test-input.test"
|
||||
|
||||
describe("detectCompletionInSessionMessages", () => {
|
||||
describe("#given session with prior DONE and new messages", () => {
|
||||
@@ -58,6 +58,29 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
// #then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#when sinceMessageIndex equals current message count #then should NOT rescan old DONE", async () => {
|
||||
// #given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "Old completion <promise>DONE</promise>" }],
|
||||
},
|
||||
]
|
||||
const ctx = createPluginInput(messages)
|
||||
|
||||
// #when
|
||||
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: "session-123",
|
||||
promise: "DONE",
|
||||
apiTimeoutMs: 1000,
|
||||
directory: "/tmp",
|
||||
sinceMessageIndex: messages.length,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no sinceMessageIndex (backward compat)", () => {
|
||||
|
||||
@@ -130,8 +130,8 @@ export async function detectCompletionInSessionMessages(
|
||||
: []
|
||||
|
||||
const scopedMessages =
|
||||
typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length
|
||||
? messageArray.slice(options.sinceMessageIndex)
|
||||
typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0
|
||||
? messageArray.slice(Math.min(options.sinceMessageIndex, messageArray.length))
|
||||
: messageArray
|
||||
|
||||
const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { detectCompletionInSessionMessages } from "./completion-promise-detector"
|
||||
import { createPluginInput } from "./completion-promise-detector-test-input"
|
||||
import { createPluginInput } from "./completion-promise-detector-test-input.test"
|
||||
|
||||
describe("detectCompletionInSessionMessages negative cases", () => {
|
||||
describe("#given natural language completion text without explicit promise", () => {
|
||||
|
||||
@@ -2,6 +2,63 @@ import { describe, expect, test } from "bun:test"
|
||||
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||
|
||||
describe("ralph-loop continuation prompt injector", () => {
|
||||
test("#given promptAsync resolves SDK error #when injecting continuation prompt #then it returns rejection without throwing", async () => {
|
||||
// given
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async () => ({
|
||||
error: { message: "prompt rejected by OpenCode" },
|
||||
response: { status: 400 },
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await injectContinuationPrompt(ctx as never, {
|
||||
sessionID: "ses_rejected_fields_response",
|
||||
prompt: "continue",
|
||||
directory: "/tmp/test",
|
||||
apiTimeoutMs: 50,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.status).toBe("rejected")
|
||||
if (result.status === "rejected") {
|
||||
expect(String(result.error)).toContain("prompt rejected by OpenCode")
|
||||
}
|
||||
})
|
||||
|
||||
test("#given promptAsync rejects #when injecting continuation prompt #then it returns rejection without throwing", async () => {
|
||||
// given
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async () => {
|
||||
throw new Error("network rejected promptAsync")
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await injectContinuationPrompt(ctx as never, {
|
||||
sessionID: "ses_rejected_promise",
|
||||
prompt: "continue",
|
||||
directory: "/tmp/test",
|
||||
apiTimeoutMs: 50,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.status).toBe("rejected")
|
||||
if (result.status === "rejected") {
|
||||
expect(String(result.error)).toContain("network rejected promptAsync")
|
||||
}
|
||||
})
|
||||
|
||||
test("#given inherited message agent has ZWSP prefix #when injecting continuation prompt #then promptAsync receives normalized agent", async () => {
|
||||
// given
|
||||
let promptBody: { agent?: string } | undefined
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getMessageDir } from "./message-storage-directory"
|
||||
import { withTimeout } from "./with-timeout"
|
||||
import {
|
||||
createInternalAgentTextPart,
|
||||
isRecord,
|
||||
normalizeSDKResponse,
|
||||
resolveInheritedPromptTools,
|
||||
} from "../../shared"
|
||||
@@ -18,6 +19,45 @@ type MessageInfo = {
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
|
||||
export type ContinuationPromptResult =
|
||||
| { status: "dispatched" }
|
||||
| { status: "rejected"; error: Error }
|
||||
|
||||
function extractPromptAsyncError(response: unknown): unknown | undefined {
|
||||
if (!isRecord(response) || !Object.hasOwn(response, "error")) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return response.error ?? "Unknown promptAsync error"
|
||||
}
|
||||
|
||||
function describePromptAsyncError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
|
||||
if (isRecord(error)) {
|
||||
const message = error.message
|
||||
if (typeof message === "string") {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error)
|
||||
} catch {
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptAsyncError(prefix: string, error: unknown): Error {
|
||||
return new Error(`${prefix}: ${describePromptAsyncError(error)}`)
|
||||
}
|
||||
|
||||
export async function injectContinuationPrompt(
|
||||
ctx: PluginInput,
|
||||
options: {
|
||||
@@ -27,7 +67,7 @@ export async function injectContinuationPrompt(
|
||||
apiTimeoutMs: number
|
||||
inheritFromSessionID?: string
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<ContinuationPromptResult> {
|
||||
let agent: string | undefined
|
||||
let model: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let tools: Record<string, boolean | "allow" | "deny" | "ask"> | undefined
|
||||
@@ -77,17 +117,39 @@ export async function injectContinuationPrompt(
|
||||
: undefined
|
||||
const launchVariant = model?.variant
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: options.sessionID },
|
||||
body: {
|
||||
...(cleanAgent !== undefined ? { agent: cleanAgent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(options.prompt)],
|
||||
},
|
||||
query: { directory: options.directory },
|
||||
})
|
||||
let response: unknown
|
||||
try {
|
||||
response = await ctx.client.session.promptAsync({
|
||||
path: { id: options.sessionID },
|
||||
body: {
|
||||
...(cleanAgent !== undefined ? { agent: cleanAgent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(options.prompt)],
|
||||
},
|
||||
query: { directory: options.directory },
|
||||
})
|
||||
} catch (error) {
|
||||
const promptError = error instanceof Error
|
||||
? error
|
||||
: createPromptAsyncError("promptAsync rejected", error)
|
||||
log("[ralph-loop] continuation prompt rejected", {
|
||||
sessionID: options.sessionID,
|
||||
error: String(promptError),
|
||||
})
|
||||
return { status: "rejected", error: promptError }
|
||||
}
|
||||
const promptError = extractPromptAsyncError(response)
|
||||
if (promptError !== undefined) {
|
||||
const error = createPromptAsyncError("promptAsync returned error", promptError)
|
||||
log("[ralph-loop] continuation prompt rejected", {
|
||||
sessionID: options.sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
return { status: "rejected", error }
|
||||
}
|
||||
|
||||
log("[ralph-loop] continuation injected", { sessionID: options.sessionID })
|
||||
return { status: "dispatched" }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createRalphLoopHook } from "./index"
|
||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||
import { handleDetectedCompletion } from "./completion-handler"
|
||||
import { clearState, writeState } from "./storage"
|
||||
import { handleFailedVerification } from "./verification-failure-handler"
|
||||
|
||||
@@ -77,6 +78,53 @@ describe("ralph-loop dispatch failure invariants", () => {
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given idle path #when promptAsync resolves SDK error #then no state or toast advance", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async () => ({
|
||||
error: { message: "prompt rejected by OpenCode" },
|
||||
response: { status: 400 },
|
||||
}),
|
||||
prompt: async () => ({}),
|
||||
create: async () => ({ data: { id: "new-session-id" } }),
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
})
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// when
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false)
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("prompt rejected by OpenCode"))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given error retry path #when promptAsync throws #then no state or toast advance", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
@@ -198,6 +246,78 @@ describe("ralph-loop dispatch failure invariants", () => {
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("Verification continuation rejected"))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given verification-failure path #when promptAsync resolves SDK error #then continuation toast is not shown", async () => {
|
||||
// given
|
||||
const parentTranscriptPath = join(testDirectory, "transcript-parent.jsonl")
|
||||
const oracleTranscriptPath = join(testDirectory, "transcript-oracle.jsonl")
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
if (options.path.id === "session-123") {
|
||||
return { data: [{}, {}, {}] }
|
||||
}
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: { body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
if (options.body.parts[0]?.text.includes("Verification failed")) {
|
||||
return {
|
||||
error: { message: "verification continuation rejected by OpenCode" },
|
||||
response: { status: 400 },
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
create: async () => ({ data: { id: "new-session-id" } }),
|
||||
},
|
||||
tui: {
|
||||
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
getTranscriptPath: (sessionID): string => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath,
|
||||
})
|
||||
|
||||
hook.startLoop("session-123", "Build API", { ultrawork: true })
|
||||
writeState(testDirectory, {
|
||||
...hook.getState()!,
|
||||
iteration: 2,
|
||||
verification_pending: true,
|
||||
verification_session_id: "ses-oracle",
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
initial_completion_promise: "DONE",
|
||||
})
|
||||
writeFileSync(
|
||||
oracleTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed" } })}\n`,
|
||||
)
|
||||
|
||||
// when
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } })
|
||||
|
||||
// then
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false)
|
||||
expect(
|
||||
toastCalls.some(
|
||||
(toast) =>
|
||||
toast.title === "Ralph Loop Failed"
|
||||
&& toast.message.includes("verification continuation rejected by OpenCode"),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("#given reset strategy #when createIterationSession returns null #then dispatch failure surfaces", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
@@ -364,6 +484,75 @@ describe("ralph-loop dispatch failure invariants", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("#given ultrawork completion path #when verification prompt resolves SDK error #then oracle-required toast is not shown", async () => {
|
||||
// given
|
||||
let cleared = false
|
||||
const loopState = {
|
||||
clear: () => {
|
||||
cleared = true
|
||||
return true
|
||||
},
|
||||
markVerificationPending: (sessionID: string) => ({
|
||||
active: true,
|
||||
iteration: 2,
|
||||
prompt: "Build API",
|
||||
started_at: new Date().toISOString(),
|
||||
session_id: sessionID,
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
verification_pending: true,
|
||||
}),
|
||||
}
|
||||
|
||||
await handleDetectedCompletion({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async () => ({
|
||||
error: { message: "verification prompt rejected by OpenCode" },
|
||||
response: { status: 400 },
|
||||
}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: (options: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push(options.body)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
sessionID: "session-123",
|
||||
state: {
|
||||
active: true,
|
||||
iteration: 2,
|
||||
prompt: "Build API",
|
||||
started_at: new Date().toISOString(),
|
||||
session_id: "session-123",
|
||||
completion_promise: "DONE",
|
||||
ultrawork: true,
|
||||
},
|
||||
loopState,
|
||||
directory: testDirectory,
|
||||
apiTimeoutMs: 5000,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(cleared).toBe(true)
|
||||
expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false)
|
||||
expect(
|
||||
toastCalls.some(
|
||||
(toast) =>
|
||||
toast.title === "Ralph Loop Failed"
|
||||
&& toast.message.includes("verification prompt rejected by OpenCode"),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.variant === "error")).toBe(true)
|
||||
})
|
||||
|
||||
test("#given reset strategy #when session.create throws #then dispatch failure surfaces", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook({
|
||||
|
||||
@@ -288,7 +288,7 @@ describe("ralph-loop", () => {
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
properties: { sessionID: "session-123", synthetic: true },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -304,6 +304,81 @@ describe("ralph-loop", () => {
|
||||
expect(state?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("#given synthetic and real idle arrive back-to-back #then only one continuation is injected for the same iteration", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
|
||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// when
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123", synthetic: true },
|
||||
},
|
||||
})
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("#given new activity after an idle continuation #when session idles again #then next iteration can continue", async () => {
|
||||
// given
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
|
||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptCalls.length).toBe(2)
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
})
|
||||
|
||||
test("should inject continuation when idle event carries session id in info", async () => {
|
||||
// given - active loop state and nested session event shape
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-info-idle", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// when - session goes idle with id under info
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { info: { id: "session-info-idle" } },
|
||||
},
|
||||
})
|
||||
|
||||
// then - continuation should be injected for that session
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-info-idle")
|
||||
expect(promptCalls[0].text).toContain("RALPH LOOP")
|
||||
})
|
||||
|
||||
test("should settle idle before injecting continuation", async () => {
|
||||
// given - active loop state with a configured idle settle delay
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
|
||||
|
||||
@@ -39,13 +39,16 @@ export async function continueIteration(
|
||||
}
|
||||
|
||||
try {
|
||||
await injectContinuationPrompt(ctx, {
|
||||
const promptResult = await injectContinuationPrompt(ctx, {
|
||||
sessionID: newSessionID,
|
||||
inheritFromSessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
if (promptResult.status === "rejected") {
|
||||
return { status: "dispatch_rejected", error: promptResult.error }
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
return { status: "dispatch_rejected", error }
|
||||
}
|
||||
@@ -65,12 +68,15 @@ export async function continueIteration(
|
||||
}
|
||||
|
||||
try {
|
||||
await injectContinuationPrompt(ctx, {
|
||||
const promptResult = await injectContinuationPrompt(ctx, {
|
||||
sessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
if (promptResult.status === "rejected") {
|
||||
return { status: "dispatch_rejected", error: promptResult.error }
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
return { status: "dispatch_rejected", error }
|
||||
}
|
||||
|
||||
@@ -213,6 +213,77 @@ describe("ralph-loop non-abort error continuation", () => {
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
})
|
||||
|
||||
test("continues after retry run activity from legacy message.part.updated part session id", async () => {
|
||||
// given - an active loop retries a recoverable runtime error
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when - the retried run emits legacy assistant activity before any stale idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "session-123",
|
||||
type: "text",
|
||||
text: "working",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then - the real idle is allowed to continue the loop
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
})
|
||||
|
||||
test("skips immediate runtime retry while background tasks are running", async () => {
|
||||
// given - an active loop owns running background work
|
||||
const hook = createRalphLoopHook({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { handleDetectedCompletion } from "./completion-handler"
|
||||
@@ -11,6 +12,8 @@ import { continueIteration } from "./iteration-continuation"
|
||||
import { handlePendingVerification } from "./pending-verification-handler"
|
||||
import { handleDeletedLoopSession, handleErroredLoopSession } from "./session-event-handler"
|
||||
|
||||
const RAPID_IDLE_DEDUP_MS = 500
|
||||
|
||||
type LoopStateController = {
|
||||
getState: () => RalphLoopState | null
|
||||
clear: () => boolean
|
||||
@@ -36,12 +39,6 @@ function hasRunningBackgroundTasks(
|
||||
: false
|
||||
}
|
||||
|
||||
function getInfoSessionID(props: Record<string, unknown> | undefined): string | undefined {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID
|
||||
return typeof sessionID === "string" ? sessionID : undefined
|
||||
}
|
||||
|
||||
function getRuntimeRetryActivitySessionID(
|
||||
eventType: string,
|
||||
props: Record<string, unknown> | undefined,
|
||||
@@ -49,25 +46,28 @@ function getRuntimeRetryActivitySessionID(
|
||||
if (eventType === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const role = info?.role
|
||||
return role === "assistant" ? getInfoSessionID(props) : undefined
|
||||
return role === "assistant" ? resolveMessageEventSessionID(props) : undefined
|
||||
}
|
||||
|
||||
if (eventType === "message.part.updated") {
|
||||
if (typeof props?.sessionID === "string") return props.sessionID
|
||||
return getInfoSessionID(props)
|
||||
return resolveMessageEventSessionID(props)
|
||||
}
|
||||
|
||||
if (eventType === "message.part.delta") {
|
||||
return typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
return resolveMessageEventSessionID(props)
|
||||
}
|
||||
|
||||
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||
return typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
return resolveMessageEventSessionID(props)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isSyntheticIdle(props: Record<string, unknown> | undefined): boolean {
|
||||
return props?.synthetic === true
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return typeof error === "object"
|
||||
&& error !== null
|
||||
@@ -189,17 +189,20 @@ export function createRalphLoopEventHandler(
|
||||
) {
|
||||
const inFlightSessions = new Set<string>()
|
||||
const runtimeErrorRetriedSessions = new Map<string, number>()
|
||||
const recentHandledSyntheticIdleAt = new Map<string, number>()
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
|
||||
if (runtimeRetryActivitySessionID) {
|
||||
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
|
||||
recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID)
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
const syntheticIdle = isSyntheticIdle(props)
|
||||
|
||||
if (inFlightSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
|
||||
@@ -247,6 +250,17 @@ export function createRalphLoopEventHandler(
|
||||
return
|
||||
}
|
||||
|
||||
const lastHandledSyntheticIdleAt = recentHandledSyntheticIdleAt.get(sessionID)
|
||||
const now = Date.now()
|
||||
if (!syntheticIdle && lastHandledSyntheticIdleAt !== undefined && now - lastHandledSyntheticIdleAt < RAPID_IDLE_DEDUP_MS) {
|
||||
recentHandledSyntheticIdleAt.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Skipped: duplicate real idle after synthetic idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
if (syntheticIdle) {
|
||||
recentHandledSyntheticIdleAt.set(sessionID, now)
|
||||
}
|
||||
|
||||
if (await handleCompletionIfDetected(ctx, options, {
|
||||
sessionID,
|
||||
state,
|
||||
@@ -389,7 +403,7 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const error = props?.error
|
||||
if (!sessionID || isAbortError(error)) {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRalphLoopHook } from "./index"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
function createDeferred(): {
|
||||
promise: Promise<void>
|
||||
@@ -44,7 +45,7 @@ describe("ralph-loop reset strategy race condition", () => {
|
||||
const selectSessionDeferred = createDeferred()
|
||||
|
||||
const hook = createRalphLoopHook(
|
||||
{
|
||||
unsafeTestValue<Parameters<typeof createRalphLoopHook>[0]>({
|
||||
directory: process.cwd(),
|
||||
client: {
|
||||
session: {
|
||||
@@ -86,7 +87,7 @@ describe("ralph-loop reset strategy race condition", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createRalphLoopHook>[0],
|
||||
}),
|
||||
{ idleSettleMs: 0 },
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import type { RalphLoopState } from "./types"
|
||||
|
||||
@@ -11,13 +12,13 @@ export function handleDeletedLoopSession(
|
||||
props: Record<string, unknown> | undefined,
|
||||
loopState: LoopStateController,
|
||||
): boolean {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (!sessionInfo?.id) return false
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return false
|
||||
|
||||
const state = loopState.getState()
|
||||
if (state?.session_id === sessionInfo.id) {
|
||||
if (state?.session_id === sessionID) {
|
||||
loopState.clear()
|
||||
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
|
||||
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID })
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -26,7 +27,7 @@ export function handleErroredLoopSession(
|
||||
props: Record<string, unknown> | undefined,
|
||||
loopState: LoopStateController,
|
||||
): boolean {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
|
||||
if (error?.name === "MessageAbortedError") {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path"
|
||||
import { createRalphLoopHook } from "./index"
|
||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||
import { clearState, writeState } from "./storage"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("ulw-loop verification", () => {
|
||||
const testDir = join(tmpdir(), `ulw-loop-verification-${Date.now()}`)
|
||||
@@ -15,7 +16,7 @@ describe("ulw-loop verification", () => {
|
||||
let oracleTranscriptPath: string
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
return unsafeTestValue<Parameters<typeof createRalphLoopHook>[0]>({
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
@@ -39,7 +40,7 @@ describe("ulw-loop verification", () => {
|
||||
},
|
||||
},
|
||||
directory: testDir,
|
||||
} as unknown as Parameters<typeof createRalphLoopHook>[0]
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -98,12 +98,26 @@ export async function handleFailedVerification(
|
||||
const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 }
|
||||
|
||||
try {
|
||||
await injectContinuationPrompt(ctx, {
|
||||
const promptResult = await injectContinuationPrompt(ctx, {
|
||||
sessionID: parentSessionID,
|
||||
prompt: buildVerificationFailurePrompt(previewState),
|
||||
directory,
|
||||
apiTimeoutMs,
|
||||
})
|
||||
if (promptResult.status === "rejected") {
|
||||
log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, {
|
||||
parentSessionID,
|
||||
error: String(promptResult.error),
|
||||
})
|
||||
loopState.clear()
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Failed",
|
||||
message: `Verification continuation rejected: ${String(promptResult.error)}`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, {
|
||||
parentSessionID,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
import { getRuleInjectionFilePath } from "./output-path";
|
||||
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
||||
import { createRuleInjectionProcessor } from "./injector";
|
||||
@@ -80,16 +81,15 @@ export function createRulesInjectorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionState(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
clearSessionState(sessionID);
|
||||
}
|
||||
clearProjectRootCache();
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
clearSessionState(sessionID);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isAbortError } from "../../shared/is-abort-error"
|
||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
import { createSessionStatusHandler } from "./session-status-handler"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps
|
||||
@@ -30,7 +31,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
|
||||
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
||||
const sessionID = sessionInfo?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const model = sessionInfo?.model
|
||||
|
||||
if (sessionID && model) {
|
||||
@@ -41,8 +42,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionDeleted = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
const sessionID = sessionInfo?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
|
||||
if (sessionID) {
|
||||
log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID })
|
||||
@@ -58,7 +58,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionStop = async (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
||||
@@ -73,7 +73,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
|
||||
const handleMessageUpdated = (props: Record<string, unknown> | undefined) => {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID || role !== "user") return
|
||||
|
||||
@@ -81,7 +81,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (cancelledSessions.has(sessionID)) {
|
||||
@@ -111,7 +111,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionError = async (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const error = props?.error
|
||||
const agent = props?.agent as string | undefined
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("runtime-fallback fallback-models", () => {
|
||||
afterEach(() => {
|
||||
@@ -12,13 +13,13 @@ describe("runtime-fallback fallback-models", () => {
|
||||
//#given
|
||||
const sessionID = "ses_runtime_fallback_category"
|
||||
SessionCategoryRegistry.register(sessionID, "quick")
|
||||
const pluginConfig = {
|
||||
const pluginConfig = unsafeTestValue({
|
||||
categories: {
|
||||
quick: {
|
||||
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"],
|
||||
},
|
||||
},
|
||||
} as any
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = getFallbackModelsForSession(sessionID, undefined, pluginConfig)
|
||||
@@ -29,13 +30,13 @@ describe("runtime-fallback fallback-models", () => {
|
||||
|
||||
test("uses agent-specific fallback_models when agent is resolved", () => {
|
||||
//#given
|
||||
const pluginConfig = {
|
||||
const pluginConfig = unsafeTestValue({
|
||||
agents: {
|
||||
oracle: {
|
||||
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"],
|
||||
},
|
||||
},
|
||||
} as any
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = getFallbackModelsForSession("ses_runtime_fallback_agent", "oracle", pluginConfig)
|
||||
@@ -46,7 +47,7 @@ describe("runtime-fallback fallback-models", () => {
|
||||
|
||||
test("does not fall back to another agent chain when agent cannot be resolved", () => {
|
||||
//#given
|
||||
const pluginConfig = {
|
||||
const pluginConfig = unsafeTestValue({
|
||||
agents: {
|
||||
sisyphus: {
|
||||
fallback_models: ["quotio/gpt-5.2", "quotio/glm-5", "quotio/kimi-k2.5"],
|
||||
@@ -55,7 +56,7 @@ describe("runtime-fallback fallback-models", () => {
|
||||
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-7"],
|
||||
},
|
||||
},
|
||||
} as any
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = getFallbackModelsForSession("ses_runtime_fallback_unknown", undefined, pluginConfig)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
|
||||
import * as loggerModule from "../../shared/logger"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type RuntimeFallbackModule = typeof import("./hook")
|
||||
|
||||
@@ -41,7 +42,7 @@ describe("runtime-fallback", () => {
|
||||
abort?: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
}) {
|
||||
return {
|
||||
return unsafeTestValue({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => {
|
||||
@@ -59,7 +60,7 @@ describe("runtime-fallback", () => {
|
||||
},
|
||||
},
|
||||
directory: "/test/dir",
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
function createMockConfig(overrides?: Partial<RuntimeFallbackConfig>): RuntimeFallbackConfig {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
import { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||
|
||||
export { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||
|
||||
@@ -18,7 +19,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
||||
|
||||
return async (props: Record<string, unknown> | undefined) => {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const timeoutEnabled = config.timeout_seconds > 0
|
||||
const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||
const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
|
||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export function createSessionStatusHandler(
|
||||
deps: HookDeps,
|
||||
@@ -22,7 +23,7 @@ export function createSessionStatusHandler(
|
||||
} = deps
|
||||
|
||||
return async (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
|
||||
const agent = props?.agent as string | undefined
|
||||
const model = props?.model as string | undefined
|
||||
|
||||
@@ -23,6 +23,15 @@ export function getSessionID(properties: EventProperties): string | undefined {
|
||||
const infoSessionId = info?.sessionId
|
||||
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
|
||||
|
||||
const part = properties?.part
|
||||
if (isRecord(part)) {
|
||||
const partSessionID = part.sessionID
|
||||
if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID
|
||||
|
||||
const partSessionId = part.sessionId
|
||||
if (typeof partSessionId === "string" && partSessionId.length > 0) return partSessionId
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:
|
||||
import * as sender from "./session-notification-sender"
|
||||
import * as utils from "./session-notification-utils"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { unsafeTestValue } from "../../test-support/unsafe-test-value"
|
||||
|
||||
|
||||
|
||||
@@ -80,7 +81,7 @@ describe("session-notification-sender", () => {
|
||||
describe("#when calling ctx.$ for notifications", () => {
|
||||
test("#then should call .quiet() on all shell commands to suppress stdout/stderr", async () => {
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -95,7 +96,7 @@ describe("session-notification-sender", () => {
|
||||
promise.nothrow = () => promise
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||
|
||||
@@ -107,7 +108,7 @@ describe("session-notification-sender", () => {
|
||||
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null)
|
||||
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -130,7 +131,7 @@ describe("session-notification-sender", () => {
|
||||
}
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||
|
||||
@@ -142,9 +143,9 @@ describe("session-notification-sender", () => {
|
||||
spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux")
|
||||
|
||||
const calls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: createShellPromise((cmdStr) => { calls.push(cmdStr) }),
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||
|
||||
@@ -157,9 +158,9 @@ describe("session-notification-sender", () => {
|
||||
test("#then should fall back to terminal-notifier when cmux fails", async () => {
|
||||
spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux")
|
||||
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")),
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
const originalFactory = mockCtx.$
|
||||
const trackingCalls: string[] = []
|
||||
@@ -180,9 +181,9 @@ describe("session-notification-sender", () => {
|
||||
spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux")
|
||||
|
||||
const trackingCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")),
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
const originalFactory = mockCtx.$
|
||||
mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
@@ -200,9 +201,9 @@ describe("session-notification-sender", () => {
|
||||
|
||||
test("#then should skip cmux when not available and use terminal-notifier", async () => {
|
||||
const calls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: createShellPromise((cmdStr) => { calls.push(cmdStr) }),
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
|
||||
|
||||
@@ -213,7 +214,7 @@ describe("session-notification-sender", () => {
|
||||
|
||||
test("#then should call .quiet() on linux notify-send", async () => {
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -236,7 +237,7 @@ describe("session-notification-sender", () => {
|
||||
}
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.sendSessionNotification(mockCtx, "linux", "Test", "Message")
|
||||
|
||||
@@ -246,7 +247,7 @@ describe("session-notification-sender", () => {
|
||||
|
||||
test("#then should call .quiet() on win32 powershell", async () => {
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -269,7 +270,7 @@ describe("session-notification-sender", () => {
|
||||
}
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.sendSessionNotification(mockCtx, "win32", "Test", "Message")
|
||||
|
||||
@@ -283,7 +284,7 @@ describe("session-notification-sender", () => {
|
||||
describe("#when calling ctx.$ for sound playback", () => {
|
||||
test("#then should call .quiet() on darwin afplay", async () => {
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -306,7 +307,7 @@ describe("session-notification-sender", () => {
|
||||
}
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff")
|
||||
|
||||
@@ -316,7 +317,7 @@ describe("session-notification-sender", () => {
|
||||
|
||||
test("#then should call .quiet() on linux paplay", async () => {
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -339,7 +340,7 @@ describe("session-notification-sender", () => {
|
||||
}
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga")
|
||||
|
||||
@@ -351,7 +352,7 @@ describe("session-notification-sender", () => {
|
||||
spyOn(utils, "getPaplayPath").mockResolvedValue(null)
|
||||
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -374,7 +375,7 @@ describe("session-notification-sender", () => {
|
||||
}
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga")
|
||||
|
||||
@@ -384,7 +385,7 @@ describe("session-notification-sender", () => {
|
||||
|
||||
test("#then should call .quiet() on win32 powershell sound", async () => {
|
||||
const quietCalls: string[] = []
|
||||
const mockCtx = {
|
||||
const mockCtx = unsafeTestValue<PluginInput>({
|
||||
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
|
||||
@@ -407,7 +408,7 @@ describe("session-notification-sender", () => {
|
||||
}
|
||||
return promise
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
|
||||
await sender.playSessionNotificationSound(mockCtx, "win32", "C:\\sound.wav")
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
declare const Bun: {
|
||||
which(commandName: string): string | null
|
||||
}
|
||||
import { bunWhich } from "../shared/bun-which-shim"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
async function findCommand(commandName: string): Promise<string | null> {
|
||||
try {
|
||||
return Bun.which(commandName)
|
||||
return bunWhich(commandName)
|
||||
} catch (error) {
|
||||
log("[session-notification] failed to resolve command path", {
|
||||
commandName,
|
||||
|
||||
@@ -375,6 +375,47 @@ describe("session-notification", () => {
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should mark session activity on message.part.updated event with part session id", async () => {
|
||||
// given - main session is set
|
||||
const mainSessionID = "main-part-activity"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 50,
|
||||
skipIfIncompleteTodos: false,
|
||||
activityGracePeriodMs: 0,
|
||||
})
|
||||
|
||||
// when - session goes idle, then streamed assistant activity fires
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: mainSessionID,
|
||||
type: "text",
|
||||
text: "still working",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for idle delay to pass
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// then - notification should NOT be sent (streaming activity cancelled it)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should mark session activity on tool.execute.before event", async () => {
|
||||
// given - main session is set
|
||||
const mainSessionID = "main-tool"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEventToolName, getQuestionText, getSessionID } from "./session-notif
|
||||
import { hasIncompleteTodos } from "./session-todo-status"
|
||||
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||
import { createSessionNotificationInit } from "./session-notification-init"
|
||||
import { resolveSessionEventID } from "../shared/event-session-id"
|
||||
|
||||
interface SessionNotificationConfig {
|
||||
title?: string
|
||||
@@ -98,8 +99,7 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.created") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.id as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||
return
|
||||
}
|
||||
@@ -116,7 +116,11 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
if (
|
||||
event.type === "message.updated" ||
|
||||
event.type === "message.part.updated" ||
|
||||
event.type === "message.part.delta"
|
||||
) {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = getSessionID({ ...props, info })
|
||||
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||
@@ -165,8 +169,8 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) scheduler.deleteSession(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ type ClientWithPromptAsync = {
|
||||
}
|
||||
}
|
||||
|
||||
function hasPromptAsync(client: Client): client is Client & ClientWithPromptAsync {
|
||||
return "promptAsync" in client.session && typeof client.session.promptAsync === "function"
|
||||
}
|
||||
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
@@ -111,7 +115,11 @@ export async function recoverToolResultMissing(
|
||||
}
|
||||
|
||||
try {
|
||||
await (client as unknown as ClientWithPromptAsync).session.promptAsync(promptInput)
|
||||
if (!hasPromptAsync(client)) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.promptAsync(promptInput)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { unsafeTestValue } from "../../../../test-support/unsafe-test-value"
|
||||
async function importFreshReaders() {
|
||||
const token = `${Date.now()}-${Math.random()}`
|
||||
const [{ readMessagesFromSDK, readMessages }, { readPartsFromSDK, readParts }] = await Promise.all([
|
||||
@@ -13,7 +14,7 @@ function createMockClient(handlers: {
|
||||
messages?: (sessionID: string) => unknown[]
|
||||
message?: (sessionID: string, messageID: string) => unknown
|
||||
}) {
|
||||
return {
|
||||
return unsafeTestValue({
|
||||
session: {
|
||||
messages: async (opts: { path: { id: string } }) => {
|
||||
if (handlers.messages) {
|
||||
@@ -28,7 +29,7 @@ function createMockClient(handlers: {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
},
|
||||
} as unknown
|
||||
})
|
||||
}
|
||||
|
||||
describe("session-recovery storage SDK readers", () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
import * as worktreeDetector from "./worktree-detector"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("start-work hook", () => {
|
||||
let testDir: string
|
||||
@@ -738,7 +739,7 @@ You are starting a Sisyphus work session.
|
||||
const promptAsyncMock = spyOn({
|
||||
promptAsync: async (_request: unknown) => undefined,
|
||||
}, "promptAsync")
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -747,7 +748,7 @@ You are starting a Sisyphus work session.
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0]
|
||||
})
|
||||
const startWorkHook = createStartWorkHook(ctx)
|
||||
const atlasHook = createAtlasHook(ctx)
|
||||
const output = {
|
||||
@@ -784,18 +785,18 @@ You are starting a Sisyphus work session.
|
||||
promptAsync: async (_request: unknown) => undefined,
|
||||
}, "promptAsync")
|
||||
|
||||
globalThis.setTimeout = ((callback: Function, delay?: number, ...args: unknown[]) => {
|
||||
globalThis.setTimeout = unsafeTestValue<typeof setTimeout>(((callback: Function, delay?: number, ...args: unknown[]) => {
|
||||
const normalized = typeof delay === "number" ? delay : 0
|
||||
if (normalized >= 5000) {
|
||||
const id = nextTimerId++
|
||||
capturedTimers.set(id, { callback: () => callback(...args), cleared: false })
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
|
||||
}
|
||||
|
||||
return originalSetTimeout(callback as Parameters<typeof originalSetTimeout>[0], delay)
|
||||
}) as unknown as typeof setTimeout
|
||||
}))
|
||||
|
||||
globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
|
||||
globalThis.clearTimeout = unsafeTestValue<typeof clearTimeout>(((id?: number | ReturnType<typeof setTimeout>) => {
|
||||
if (typeof id === "number" && capturedTimers.has(id)) {
|
||||
capturedTimers.get(id)!.cleared = true
|
||||
capturedTimers.delete(id)
|
||||
@@ -803,11 +804,11 @@ You are starting a Sisyphus work session.
|
||||
}
|
||||
|
||||
originalClearTimeout(id as Parameters<typeof originalClearTimeout>[0])
|
||||
}) as unknown as typeof clearTimeout
|
||||
}))
|
||||
|
||||
Date.now = () => fakeNow
|
||||
|
||||
const ctx = {
|
||||
const ctx = unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
|
||||
directory: testDir,
|
||||
client: {
|
||||
session: {
|
||||
@@ -816,13 +817,13 @@ You are starting a Sisyphus work session.
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0]
|
||||
})
|
||||
const startWorkHook = createStartWorkHook(ctx)
|
||||
const atlasHook = createAtlasHook(ctx, {
|
||||
directory: testDir,
|
||||
backgroundManager: {
|
||||
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"]>({
|
||||
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
|
||||
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"],
|
||||
}),
|
||||
})
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearContinuationMarker,
|
||||
setContinuationMarkerSource,
|
||||
} from "../../features/run-continuation-state"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const HOOK_NAME = "stop-continuation-guard"
|
||||
@@ -86,11 +87,11 @@ export function createStopContinuationGuardHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clear(sessionInfo.id)
|
||||
clearContinuationMarker(ctx.directory, sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
clear(sessionID)
|
||||
clearContinuationMarker(ctx.directory, sessionID)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
||||
import { readContinuationMarker } from "../../features/run-continuation-state"
|
||||
import { createStopContinuationGuardHook } from "./index"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type CancelCall = {
|
||||
taskId: string
|
||||
@@ -31,14 +32,14 @@ describe("stop-continuation-guard", () => {
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
return unsafeTestValue<PluginInput>({
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
directory: createTempDir(),
|
||||
} as unknown as PluginInput
|
||||
})
|
||||
}
|
||||
|
||||
function createBackgroundTask(status: BackgroundTask["status"], id: string): BackgroundTask {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
const TASK_TOOLS = new Set([
|
||||
"task",
|
||||
"task_create",
|
||||
@@ -50,8 +52,7 @@ export function createTaskReminderHook(_ctx: PluginInput) {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type !== "session.deleted") return
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
const sessionId = props?.info?.id
|
||||
const sessionId = resolveSessionEventID(event.properties)
|
||||
if (!sessionId) return
|
||||
sessionCounters.delete(sessionId)
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { createTaskResumeInfoHook } from "./index"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("createTaskResumeInfoHook", () => {
|
||||
const hook = createTaskResumeInfoHook()
|
||||
@@ -19,7 +20,7 @@ describe("createTaskResumeInfoHook", () => {
|
||||
const input = createInput("task")
|
||||
const output = {
|
||||
title: "delegate_task",
|
||||
output: undefined as unknown as string,
|
||||
output: unsafeTestValue<string>(undefined),
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
applyMemberSessionRouting,
|
||||
buildMemberPromptBody,
|
||||
} from "../../features/team-mode/member-session-routing"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
|
||||
@@ -35,8 +36,7 @@ export type HookImpl = (input: HookInput) => Promise<void>
|
||||
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
|
||||
|
||||
function getIdleSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
function buildWakeHint(unreadCount: number): string {
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { BackgroundManager } from "../../features/background-agent/manager"
|
||||
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
|
||||
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import type { TmuxSessionManager } from "../../features/tmux-subagent/manager"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
|
||||
function getDeletedSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { info?: { id?: string } } | undefined
|
||||
return record?.info?.id
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
async function findLeadTeamRunId(
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
|
||||
function getErroredSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import type { RuntimeStateMember } from "../../features/team-mode/types"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
@@ -13,13 +14,11 @@ const IDLE_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["run
|
||||
const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running", "idle", "pending"])
|
||||
|
||||
function getSessionIDFromIdleEvent(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
function getSessionIDFromDeletedEvent(properties: unknown): string | undefined {
|
||||
const record = properties as { info?: { id?: string } } | undefined
|
||||
return record?.info?.id
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
async function transitionMemberStatus(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { detectThinkKeyword, extractPromptText } from "./detector"
|
||||
import { isAlreadyHighVariant } from "./switcher"
|
||||
import type { ThinkModeState } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
const thinkModeState = new Map<string, ThinkModeState>()
|
||||
|
||||
@@ -66,9 +67,9 @@ export function createThinkModeHook() {
|
||||
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
if (props?.info?.id) {
|
||||
thinkModeState.delete(props.info.id)
|
||||
const sessionID = resolveSessionEventID(event.properties)
|
||||
if (sessionID) {
|
||||
thinkModeState.delete(sessionID)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearContinuationMarker,
|
||||
} from "../../features/run-continuation-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
|
||||
import { armCompactionGuard } from "./compaction-guard"
|
||||
@@ -71,7 +72,7 @@ export function createTodoContinuationHandler(args: {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const error = extractSessionErrorInfo(props?.error)
|
||||
@@ -102,7 +103,7 @@ export function createTodoContinuationHandler(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
sessionStateStore.startPruneInterval()
|
||||
@@ -118,7 +119,7 @@ export function createTodoContinuationHandler(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
const compactionEpoch = armCompactionGuard(state, Date.now())
|
||||
@@ -129,9 +130,9 @@ export function createTodoContinuationHandler(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clearContinuationMarker(ctx.directory, sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
clearContinuationMarker(ctx.directory, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
@@ -12,7 +13,7 @@ export function handleNonIdleEvent(args: {
|
||||
|
||||
if (eventType === "message.updated") {
|
||||
const info = properties?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(properties)
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -50,12 +51,7 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "message.part.updated") {
|
||||
const sessionID = typeof properties?.sessionID === "string"
|
||||
? properties.sessionID
|
||||
: undefined
|
||||
const legacyInfo = properties?.info as Record<string, unknown> | undefined
|
||||
const legacySessionID = legacyInfo?.sessionID as string | undefined
|
||||
const targetSessionID = sessionID ?? legacySessionID
|
||||
const targetSessionID = resolveMessageEventSessionID(properties)
|
||||
|
||||
if (targetSessionID) {
|
||||
const state = sessionStateStore.getExistingState(targetSessionID)
|
||||
@@ -69,7 +65,7 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "message.part.delta") {
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(properties)
|
||||
if (sessionID) {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) {
|
||||
@@ -83,7 +79,7 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(properties)
|
||||
if (sessionID) {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) {
|
||||
@@ -97,10 +93,10 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "session.deleted") {
|
||||
const sessionInfo = properties?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionStateStore.cleanup(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
const sessionID = resolveSessionEventID(properties)
|
||||
if (sessionID) {
|
||||
sessionStateStore.cleanup(sessionID)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./constants"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
type FakeTimerID = number & ReturnType<typeof setTimeout> & ReturnType<typeof setInterval>
|
||||
|
||||
interface FakeTimers {
|
||||
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
|
||||
@@ -57,7 +58,7 @@ function createFakeTimers(): FakeTimers {
|
||||
callback,
|
||||
args,
|
||||
})
|
||||
return id
|
||||
return id as FakeTimerID
|
||||
}
|
||||
|
||||
const clear = (id: number | undefined) => {
|
||||
@@ -74,7 +75,7 @@ function createFakeTimers(): FakeTimers {
|
||||
if (normalized >= REAL_MAX_DELAY_MS) {
|
||||
return original.setTimeout(callback, delay, ...args)
|
||||
}
|
||||
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
|
||||
return schedule(callback, normalized, null, args)
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
@@ -85,7 +86,7 @@ function createFakeTimers(): FakeTimers {
|
||||
if (interval >= REAL_MAX_DELAY_MS) {
|
||||
return original.setInterval(callback, delay, ...args)
|
||||
}
|
||||
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
|
||||
return schedule(callback, interval, interval, args)
|
||||
}) as typeof setInterval
|
||||
|
||||
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
|
||||
@@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
}
|
||||
}
|
||||
|
||||
type MockPluginInput = Parameters<typeof createTodoContinuationEnforcer>[0]
|
||||
|
||||
let mockMessages: MockMessage[] = []
|
||||
|
||||
function createMockPluginInput() {
|
||||
@@ -225,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
}
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
@@ -233,7 +236,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
} as BackgroundManager
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -302,6 +305,26 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should inject continuation when idle event carries session id in info", async () => {
|
||||
fakeTimers.restore()
|
||||
// given - OpenCode session events can nest the session id under info
|
||||
const sessionID = "main-info-idle"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// when - session goes idle with the nested event shape
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { info: { id: sessionID } } },
|
||||
})
|
||||
|
||||
// then - continuation is still injected for that session
|
||||
await wait(2500)
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].sessionID).toBe(sessionID)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should not inject when all todos are complete", async () => {
|
||||
// given - session with all todos complete
|
||||
const sessionID = "main-456"
|
||||
@@ -527,6 +550,42 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity when message.part.updated only has part session id", async () => {
|
||||
// given - session starting countdown
|
||||
const sessionID = "main-assistant-part-only"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// when - legacy part-only sync payload reports assistant output
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "working",
|
||||
},
|
||||
time: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity with message.part.delta payload", async () => {
|
||||
// given - session starting countdown
|
||||
const sessionID = "main-assistant-delta"
|
||||
@@ -1599,7 +1658,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
@@ -1660,7 +1719,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
@@ -1712,7 +1771,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
@@ -1769,7 +1828,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
@@ -1823,7 +1882,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
@@ -1878,7 +1937,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
skipAgents: [],
|
||||
@@ -2122,7 +2181,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async () => {
|
||||
const error = new Error("prompt is too long: 150000 tokens > 100000 maximum")
|
||||
;(error as any).name = "ContextLengthError"
|
||||
error.name = "ContextLengthError"
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
export const TODOWRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for tracking progress on multi-step work.
|
||||
|
||||
## OpenCode Schema Contract
|
||||
|
||||
The upstream OpenCode \`todowrite\` schema expects each todo item to include:
|
||||
|
||||
- \`content\`: string
|
||||
- \`status\`: string, one of \`pending\`, \`in_progress\`, \`completed\`, \`cancelled\`
|
||||
- \`priority\`: string, one of \`high\`, \`medium\`, \`low\`
|
||||
|
||||
\`priority\` is a string field. Never send numeric priorities such as \`0\`, \`1\`, \`2\`, or labels such as \`P0\`, \`P1\`, \`P2\`.
|
||||
|
||||
## Todo Format (MANDATORY)
|
||||
|
||||
Each todo title MUST encode four elements: WHERE, WHY, HOW, and EXPECTED RESULT.
|
||||
|
||||
@@ -37,4 +37,14 @@ describe("createTodoDescriptionOverrideHook", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given todowrite description is overridden", () => {
|
||||
describe("#when the model reads schema guidance", () => {
|
||||
it("#then should require string priorities matching OpenCode schema", () => {
|
||||
expect(TODOWRITE_DESCRIPTION).toContain("`priority`: string")
|
||||
expect(TODOWRITE_DESCRIPTION).toContain("`high`, `medium`, `low`")
|
||||
expect(TODOWRITE_DESCRIPTION).toContain("Never send numeric priorities")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { isAbortError } from "../../shared/is-abort-error"
|
||||
import {
|
||||
buildReminder,
|
||||
@@ -128,7 +129,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID || !isAbortError(props?.error)) return
|
||||
|
||||
cancelledSessions.add(sessionID)
|
||||
@@ -138,7 +139,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
}
|
||||
|
||||
if (event.type === "session.stop") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.add(sessionID)
|
||||
@@ -149,7 +150,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID || (role !== "user" && role !== "assistant")) return
|
||||
|
||||
@@ -158,7 +159,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.delete(sessionID)
|
||||
@@ -166,16 +167,16 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (!sessionInfo?.id) return
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.delete(sessionInfo.id)
|
||||
cancelledSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type !== "session.idle") return
|
||||
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
|
||||
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export type GuardArgs = {
|
||||
filePath?: string
|
||||
@@ -108,8 +109,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: Wri
|
||||
return
|
||||
}
|
||||
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
const sessionID = props?.info?.id
|
||||
const sessionID = resolveSessionEventID(event.properties)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user