merge(dev): resolve latest manager and runtime-fallback conflicts
Sync the PR branch with the newest dev branch and resolve the new import-level conflicts in background-agent manager and runtime-fallback tests. Preserve both the delegated bootstrap coverage from this branch and the newer upstream test utilities and runtime wiring changes, then re-verify the affected delegated fallback suites and typecheck. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("buildBackgroundTaskNotificationText", () => {
|
||||
describe("#given one task still running after a completed task notification", () => {
|
||||
@@ -134,7 +135,7 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||
const notification = buildBackgroundTaskNotificationText({
|
||||
task: {
|
||||
id: "bg_abc123",
|
||||
description: undefined as unknown as string,
|
||||
description: unsafeTestValue<string>(undefined),
|
||||
status: "completed",
|
||||
},
|
||||
duration: "5s",
|
||||
@@ -142,8 +143,8 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||
allComplete: true,
|
||||
remainingCount: 0,
|
||||
completedTasks: [
|
||||
{ id: "bg_abc123", description: undefined as unknown as string, status: "completed" },
|
||||
{ id: "bg_def456", description: undefined as unknown as string, status: "completed" },
|
||||
{ id: "bg_abc123", description: unsafeTestValue<string>(undefined), status: "completed" },
|
||||
{ id: "bg_def456", description: unsafeTestValue<string>(undefined), status: "completed" },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -230,7 +231,7 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||
const notification = buildBackgroundTaskNotificationText({
|
||||
task: {
|
||||
id: "bg_xyz789",
|
||||
description: undefined as unknown as string,
|
||||
description: unsafeTestValue<string>(undefined),
|
||||
status: "completed",
|
||||
},
|
||||
duration: "3s",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { getCompactionPartStorageDir } from "../../shared/compaction-marker"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("isCompactionAgent", () => {
|
||||
describe("#given agent name variations", () => {
|
||||
@@ -49,7 +50,7 @@ describe("isCompactionAgent", () => {
|
||||
|
||||
test("returns false for null", () => {
|
||||
// when
|
||||
const result = isCompactionAgent(null as unknown as string)
|
||||
const result = isCompactionAgent(unsafeTestValue<string>(null))
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
function createManager(config?: BackgroundTaskConfig): BackgroundManager {
|
||||
const client = {
|
||||
@@ -16,12 +17,12 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager {
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
const testManager = manager as unknown as {
|
||||
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }), config: config })
|
||||
const testManager = unsafeTestValue<{
|
||||
enqueueNotificationForParent: (sessionId: string, fn: () => Promise<void>) => Promise<void>
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
tasks: Map<string, BackgroundTask>
|
||||
}
|
||||
}>(manager)
|
||||
|
||||
testManager.enqueueNotificationForParent = async (_sessionId: string, fn) => {
|
||||
await fn()
|
||||
@@ -32,7 +33,7 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager {
|
||||
}
|
||||
|
||||
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
|
||||
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
|
||||
return (unsafeTestValue<{ tasks: Map<string, BackgroundTask> }>(manager)).tasks
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
|
||||
@@ -4,8 +4,41 @@ import { tmpdir } from "node:os"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("BackgroundManager session permission", () => {
|
||||
test("passes parent directory route when prompting the child session", async () => {
|
||||
// given
|
||||
const promptCalls: Array<Record<string, unknown>> = []
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent" } }),
|
||||
create: async () => ({ data: { id: "ses_child" } }),
|
||||
promptAsync: async (input: Record<string, unknown>) => {
|
||||
promptCalls.push(input)
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }) })
|
||||
|
||||
// when
|
||||
await manager.launch({
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "explore",
|
||||
parentSessionId: "ses_parent",
|
||||
parentMessageId: "msg_parent",
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
manager.shutdown()
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.query).toEqual({ directory: "/parent" })
|
||||
})
|
||||
|
||||
test("passes query directory when loading the parent session", async () => {
|
||||
// given
|
||||
const getCalls: Array<Record<string, unknown>> = []
|
||||
@@ -21,7 +54,7 @@ describe("BackgroundManager session permission", () => {
|
||||
},
|
||||
}
|
||||
const directory = tmpdir()
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory }) })
|
||||
|
||||
// when
|
||||
await manager.launch({
|
||||
@@ -62,7 +95,7 @@ describe("BackgroundManager session permission", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }) })
|
||||
|
||||
// when
|
||||
await manager.launch({
|
||||
|
||||
@@ -345,6 +345,47 @@ describe("BackgroundManager pollRunningTasks", () => {
|
||||
expect(task.status).toBe("completed")
|
||||
expect(todoCallCount).toBe(0)
|
||||
})
|
||||
|
||||
test("#when cached incomplete todos become complete before idle polling #then refreshes todos and completes", async () => {
|
||||
//#given
|
||||
let todoCallCount = 0
|
||||
const manager = createManagerWithClient({
|
||||
status: async () => ({ data: { "ses-idle-stale-todos": { type: "idle" } } }),
|
||||
todo: async () => {
|
||||
todoCallCount += 1
|
||||
return {
|
||||
data: [
|
||||
{ content: "compile result", status: "completed", priority: "high" },
|
||||
],
|
||||
}
|
||||
},
|
||||
})
|
||||
const task = createRunningTask("ses-idle-stale-todos")
|
||||
injectTask(manager, task)
|
||||
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: "ses-idle-stale-todos", type: "text" },
|
||||
})
|
||||
manager.handleEvent({
|
||||
type: "todo.updated",
|
||||
properties: {
|
||||
sessionID: "ses-idle-stale-todos",
|
||||
todos: [
|
||||
{ content: "compile result", status: "in_progress", priority: "high" },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const poll = manager["pollRunningTasks"]
|
||||
await poll.call(manager)
|
||||
manager.shutdown()
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("completed")
|
||||
expect(todoCallCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a running task whose session status is busy", () => {
|
||||
|
||||
@@ -5248,6 +5248,54 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("completes task when session.idle carries session id in info", async () => {
|
||||
//#given
|
||||
const sessionID = "ses-info-idle-completes-task"
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "done" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task = createMockTask({
|
||||
id: "task-info-idle-completes",
|
||||
sessionId: sessionID,
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "msg-info-idle",
|
||||
description: "task completed by nested idle event",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.idle",
|
||||
properties: { info: { id: sessionID } },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("completed")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("completes task on session.status idle after todo-continuation finishes", async () => {
|
||||
//#given
|
||||
const sessionID = "ses-status-idle-after-todo-continuation"
|
||||
@@ -5972,6 +6020,54 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
expect(task.progress!.toolCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("should update lastUpdate when legacy message.part.updated only has part session id", () => {
|
||||
//#given - a running task with stale lastUpdate
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const oldUpdate = new Date(Date.now() - 300_000)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-part-only-1",
|
||||
sessionId: "session-part-only-1",
|
||||
parentSessionId: "parent-1",
|
||||
parentMessageId: "msg-1",
|
||||
description: "Legacy part-only task",
|
||||
prompt: "Keep working",
|
||||
agent: "oracle",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 600_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: oldUpdate,
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when - a legacy message.part.updated event arrives without top-level sessionID
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "session-part-only-1",
|
||||
type: "text",
|
||||
text: "still working",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - lastUpdate should be refreshed, toolCalls should remain 0
|
||||
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
|
||||
expect(task.progress!.toolCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("should update lastUpdate on thinking-type message.part.updated event", () => {
|
||||
//#given - a running task with stale lastUpdate
|
||||
const client = {
|
||||
|
||||
@@ -14,10 +14,13 @@ import {
|
||||
getAgentToolRestrictions,
|
||||
normalizePromptTools,
|
||||
normalizeSDKResponse,
|
||||
promptWithModelSuggestionRetry,
|
||||
resolveInheritedPromptTools,
|
||||
createInternalAgentTextPart,
|
||||
messagesInDirectory,
|
||||
promptAsyncInDirectory,
|
||||
promptWithRetryInDirectory,
|
||||
} from "../../shared"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
@@ -93,7 +96,6 @@ import {
|
||||
registerDelegatedChildSessionBootstrap,
|
||||
} from "../../shared/delegated-child-session-bootstrap"
|
||||
import { settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
type ParentWakePromptContext = {
|
||||
@@ -103,15 +105,6 @@ type ParentWakePromptContext = {
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
type PendingParentWake = {
|
||||
promptContext: ParentWakePromptContext
|
||||
notifications: string[]
|
||||
}
|
||||
|
||||
type SessionStatusInfo = { type?: string }
|
||||
|
||||
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
|
||||
|
||||
interface MessagePartInfo {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
@@ -122,7 +115,7 @@ interface MessagePartInfo {
|
||||
|
||||
interface EventProperties {
|
||||
sessionID?: string
|
||||
info?: { id?: string }
|
||||
info?: { id?: string; sessionID?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -233,8 +226,6 @@ export class BackgroundManager {
|
||||
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
|
||||
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private observedOutputSessions: Set<string> = new Set()
|
||||
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
|
||||
private rootDescendantCounts: Map<string, number>
|
||||
@@ -810,10 +801,10 @@ The fallback retry session is now created and can be inspected directly.
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
|
||||
promptWithModelSuggestionRetry(this.client, {
|
||||
promptWithRetryInDirectory(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: promptBody,
|
||||
}).catch(async (error) => {
|
||||
}, parentDirectory).catch(async (error) => {
|
||||
// Retry with fallback agent if the original agent was unregistered (e.g., after a model switch)
|
||||
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
|
||||
log("[background-agent] Agent not found, retrying with fallback agent", {
|
||||
@@ -826,10 +817,10 @@ The fallback retry session is now created and can be inspected directly.
|
||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||
})
|
||||
setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
|
||||
await promptWithModelSuggestionRetry(this.client, {
|
||||
await promptWithRetryInDirectory(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: fallbackBody,
|
||||
})
|
||||
}, parentDirectory)
|
||||
task.agent = FALLBACK_AGENT
|
||||
return
|
||||
} catch (retryError) {
|
||||
@@ -1175,7 +1166,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
applySessionPromptParams(existingTask.sessionId!, existingTask.model)
|
||||
}
|
||||
|
||||
this.client.session.promptAsync({
|
||||
promptAsyncInDirectory(this.client, {
|
||||
path: { id: existingTask.sessionId },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
@@ -1195,7 +1186,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
})(),
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
},
|
||||
}).catch(async (error) => {
|
||||
}, this.directory).catch(async (error) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
const errorInfo = {
|
||||
name: extractErrorName(error),
|
||||
@@ -1238,8 +1229,8 @@ The fallback retry session is now created and can be inspected directly.
|
||||
|
||||
private async checkSessionTodos(sessionID: string): Promise<boolean> {
|
||||
const observedIncompleteTodos = this.observedIncompleteTodosBySession.get(sessionID)
|
||||
if (observedIncompleteTodos !== undefined) {
|
||||
return observedIncompleteTodos
|
||||
if (observedIncompleteTodos === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1279,8 +1270,9 @@ The fallback retry session is now created and can be inspected directly.
|
||||
this.observedIncompleteTodosBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean {
|
||||
if (!partInfo?.sessionID) return false
|
||||
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean {
|
||||
if (!partInfo) return false
|
||||
if (!partInfo.sessionID && !sessionID) return false
|
||||
if (partInfo.tool) return true
|
||||
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
|
||||
if (partInfo.type === "text" || partInfo.type === "reasoning") return true
|
||||
@@ -1298,9 +1290,9 @@ The fallback retry session is now created and can be inspected directly.
|
||||
const info = props?.info
|
||||
if (!info || typeof info !== "object") return
|
||||
|
||||
const sessionID = (info as Record<string, unknown>)["sessionID"]
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = (info as Record<string, unknown>)["role"]
|
||||
if (typeof sessionID !== "string") return
|
||||
if (!sessionID) return
|
||||
|
||||
if (role === "tool") {
|
||||
this.markSessionOutputObserved(sessionID)
|
||||
@@ -1331,7 +1323,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
|
||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||
const partInfo = resolveMessagePartInfo(props)
|
||||
const sessionID = partInfo?.sessionID
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
||||
@@ -1339,7 +1331,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
|
||||
const { task } = resolved
|
||||
|
||||
if (this.hasOutputSignalFromPart(partInfo)) {
|
||||
if (this.hasOutputSignalFromPart(partInfo, sessionID)) {
|
||||
this.markSessionOutputObserved(sessionID)
|
||||
}
|
||||
|
||||
@@ -1423,7 +1415,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
|
||||
if (event.type === "todo.updated") {
|
||||
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const todos = Array.isArray(props?.todos) ? props.todos : undefined
|
||||
if (!sessionID || !todos) return
|
||||
|
||||
@@ -1438,12 +1430,6 @@ The fallback retry session is now created and can be inspected directly.
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
if (!props || typeof props !== "object") return
|
||||
const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined
|
||||
if (sessionID) {
|
||||
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
||||
log("[background-agent] Failed to flush pending parent wake:", { sessionID, error })
|
||||
})
|
||||
}
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props as Record<string, unknown>,
|
||||
findBySession: (id) => {
|
||||
@@ -1459,7 +1445,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
||||
@@ -1488,9 +1474,8 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const info = props?.info
|
||||
if (!info || typeof info.id !== "string") return
|
||||
const sessionID = info.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
this.clearSessionOutputObserved(sessionID)
|
||||
this.clearSessionTodoObservation(sessionID)
|
||||
|
||||
@@ -1548,7 +1533,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
|
||||
if (event.type === "session.status") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const status = props?.status as { type?: string; message?: string } | undefined
|
||||
if (!sessionID || !status?.type) return
|
||||
|
||||
@@ -1778,9 +1763,9 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.session.messages({
|
||||
const response = await messagesInDirectory(this.client, {
|
||||
path: { id: sessionID },
|
||||
})
|
||||
}, this.directory)
|
||||
|
||||
const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true })
|
||||
|
||||
@@ -2189,7 +2174,9 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
|
||||
if (this.enableParentSessionNotifications) {
|
||||
try {
|
||||
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionId } })
|
||||
const messagesResp = await messagesInDirectory(this.client, {
|
||||
path: { id: task.parentSessionId },
|
||||
}, this.directory)
|
||||
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
|
||||
info?: {
|
||||
agent?: string
|
||||
@@ -2250,42 +2237,30 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
...(variant !== undefined ? { variant } : {}),
|
||||
...(resolvedTools ? { tools: resolvedTools } : {}),
|
||||
}
|
||||
const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId)
|
||||
|
||||
if (shouldDeferReply) {
|
||||
this.queuePendingParentWake(task.parentSessionId, notification, parentPromptContext)
|
||||
log("[background-agent] Deferred notification until parent session is idle:", {
|
||||
try {
|
||||
await promptAsyncInDirectory(this.client, {
|
||||
path: { id: task.parentSessionId },
|
||||
body: {
|
||||
noReply: !shouldReply,
|
||||
...parentPromptContext,
|
||||
parts: [createInternalAgentTextPart(notification)],
|
||||
},
|
||||
}, this.directory)
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
isTaskFailure,
|
||||
noReply: !shouldReply,
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
await this.client.session.promptAsync({
|
||||
path: { id: task.parentSessionId },
|
||||
body: {
|
||||
noReply: !shouldReply,
|
||||
...parentPromptContext,
|
||||
parts: [createInternalAgentTextPart(notification)],
|
||||
},
|
||||
})
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
isTaskFailure,
|
||||
noReply: !shouldReply,
|
||||
deferredReply: false,
|
||||
parentSessionID: task.parentSessionId,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionId,
|
||||
})
|
||||
this.queuePendingNotification(task.parentSessionId, notification)
|
||||
} else {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
this.queuePendingNotification(task.parentSessionId, notification)
|
||||
} else {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -2307,112 +2282,6 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
return false
|
||||
}
|
||||
|
||||
private async isSessionActive(sessionID: string): Promise<boolean> {
|
||||
const sessionStatusMethod = this.client?.session?.status
|
||||
if (typeof sessionStatusMethod !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResult = await this.client.session.status()
|
||||
const statuses = normalizeSDKResponse(
|
||||
statusResult,
|
||||
{} as Record<string, SessionStatusInfo>,
|
||||
)
|
||||
const status = statuses[sessionID]
|
||||
return typeof status?.type === "string" && isActiveSessionStatus(status.type)
|
||||
} catch (error) {
|
||||
log("[background-agent] Unable to check parent session status before wake:", {
|
||||
sessionID,
|
||||
error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private queuePendingParentWake(
|
||||
sessionID: string,
|
||||
notification: string,
|
||||
promptContext: ParentWakePromptContext,
|
||||
): void {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.push(notification)
|
||||
pendingWake.promptContext = promptContext
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, {
|
||||
promptContext,
|
||||
notifications: [notification],
|
||||
})
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
}
|
||||
|
||||
private async flushPendingParentWake(sessionID: string): Promise<void> {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (!pendingWake) {
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (await this.isSessionActive(sessionID)) {
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingParentWakes.delete(sessionID)
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
await settleAfterSessionIdle()
|
||||
|
||||
if (await this.isSessionActive(sessionID)) {
|
||||
this.pendingParentWakes.set(sessionID, pendingWake)
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const notificationContent = pendingWake.notifications.join("\n\n")
|
||||
|
||||
try {
|
||||
await this.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: false,
|
||||
...pendingWake.promptContext,
|
||||
parts: [createInternalAgentTextPart(notificationContent)],
|
||||
},
|
||||
})
|
||||
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
||||
} catch (error) {
|
||||
this.queuePendingNotification(sessionID, notificationContent)
|
||||
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePendingParentWakeFlush(sessionID: string): void {
|
||||
if (this.pendingParentWakeTimers.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingParentWakeTimers.delete(sessionID)
|
||||
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
||||
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
|
||||
})
|
||||
}, PENDING_PARENT_WAKE_RETRY_MS)
|
||||
|
||||
this.pendingParentWakeTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
private clearPendingParentWakeTimer(sessionID: string): void {
|
||||
const timer = this.pendingParentWakeTimers.get(sessionID)
|
||||
if (!timer) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
this.pendingParentWakeTimers.delete(sessionID)
|
||||
}
|
||||
|
||||
private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void {
|
||||
pruneStaleTasksAndNotifications({
|
||||
tasks: this.tasks,
|
||||
@@ -2726,11 +2595,6 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
}
|
||||
this.idleDeferralTimers.clear()
|
||||
|
||||
for (const timer of this.pendingParentWakeTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.pendingParentWakeTimers.clear()
|
||||
|
||||
for (const sessionID of trackedSessionIDs) {
|
||||
subagentSessions.delete(sessionID)
|
||||
this.cleanupDelegatedSessionContext(sessionID)
|
||||
@@ -2743,7 +2607,6 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
this.pendingNotifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.notificationQueueByParent.clear()
|
||||
this.pendingParentWakes.clear()
|
||||
this.rootDescendantCounts.clear()
|
||||
this.queuesByKey.clear()
|
||||
this.processingKeys.clear()
|
||||
|
||||
@@ -2,16 +2,17 @@ import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
import { verifySessionExists } from "./session-existence"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("verifySessionExists", () => {
|
||||
test("passes query directory to session lookup when provided", async () => {
|
||||
// given
|
||||
const get = mock(async () => ({ data: { id: "session-123" } }))
|
||||
const client = {
|
||||
const client = unsafeTestValue<OpencodeClient>({
|
||||
session: {
|
||||
get,
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await verifySessionExists(client, "session-123", "/project/root")
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { log } from "../../shared"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key]
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export function handleSessionIdleBackgroundEvent(args: {
|
||||
properties: Record<string, unknown>
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
@@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
emitIdleEvent,
|
||||
} = args
|
||||
|
||||
const sessionID = getString(properties, "sessionID")
|
||||
const sessionID = resolveSessionEventID(properties)
|
||||
if (!sessionID) return
|
||||
|
||||
const task = findBySession(sessionID)
|
||||
|
||||
@@ -535,6 +535,58 @@ describe("background-agent spawner fallback model promotion", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("passes parent directory route when prompting the child session", async () => {
|
||||
// given
|
||||
const promptCalls: Array<Record<string, unknown>> = []
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent/dir" } }),
|
||||
create: async () => ({ data: { id: "ses_child_query" } }),
|
||||
promptAsync: async (input: Record<string, unknown>) => {
|
||||
promptCalls.push(input)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const task = createTask({
|
||||
description: "Test task",
|
||||
prompt: "Do work",
|
||||
agent: "sisyphus-junior",
|
||||
parentSessionId: "ses_parent",
|
||||
parentMessageId: "msg_parent",
|
||||
})
|
||||
|
||||
const item = {
|
||||
task,
|
||||
input: {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionId: task.parentSessionId,
|
||||
parentMessageId: task.parentMessageId,
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
model: task.model,
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await startTask(item as never, {
|
||||
client: client as never,
|
||||
directory: "/fallback",
|
||||
concurrencyManager: { release: () => {} } as never,
|
||||
tmuxEnabled: false,
|
||||
onTaskError: () => {},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.query).toEqual({ directory: "/parent/dir" })
|
||||
})
|
||||
|
||||
test("strips leading zwsp from prompt body agent before promptAsync", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ body?: { agent?: string } }> = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
|
||||
import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
@@ -171,10 +171,10 @@ export async function startTask(
|
||||
}
|
||||
|
||||
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
|
||||
const promptChain = promptWithModelSuggestionRetry(client, {
|
||||
const promptChain = promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: promptBody,
|
||||
}).catch(async (error) => {
|
||||
}, parentDirectory).catch(async (error) => {
|
||||
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
|
||||
log("[background-agent] Agent not found, retrying with fallback agent", {
|
||||
original: input.agent,
|
||||
@@ -182,12 +182,12 @@ export async function startTask(
|
||||
taskId: task.id,
|
||||
})
|
||||
try {
|
||||
await promptWithModelSuggestionRetry(client, {
|
||||
await promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||
}),
|
||||
})
|
||||
}, parentDirectory)
|
||||
task.agent = FALLBACK_AGENT
|
||||
return
|
||||
} catch (retryError) {
|
||||
@@ -227,18 +227,19 @@ export async function startTask(
|
||||
export async function resumeTask(
|
||||
task: BackgroundTask,
|
||||
input: ResumeInput,
|
||||
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
|
||||
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "directory" | "onTaskError">
|
||||
): Promise<void> {
|
||||
const { client, concurrencyManager, onTaskError } = ctx
|
||||
const { client, concurrencyManager, directory, onTaskError } = ctx
|
||||
|
||||
if (!task.sessionId) {
|
||||
throw new Error(`Task has no sessionID: ${task.id}`)
|
||||
}
|
||||
const sessionID = task.sessionId
|
||||
|
||||
if (task.status === "running") {
|
||||
log("[background-agent] Resume skipped - task already running:", {
|
||||
taskId: task.id,
|
||||
sessionID: task.sessionId,
|
||||
sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -262,7 +263,7 @@ export async function resumeTask(
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
subagentSessions.add(task.sessionId)
|
||||
subagentSessions.add(sessionID)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
@@ -274,10 +275,10 @@ export async function resumeTask(
|
||||
})
|
||||
}
|
||||
|
||||
log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionId })
|
||||
log("[background-agent] Resuming task:", { taskId: task.id, sessionID })
|
||||
|
||||
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
|
||||
sessionID: task.sessionId,
|
||||
sessionID,
|
||||
agent: task.agent,
|
||||
model: task.model,
|
||||
promptLength: input.prompt.length,
|
||||
@@ -291,7 +292,7 @@ export async function resumeTask(
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
applySessionPromptParams(task.sessionId, task.model)
|
||||
applySessionPromptParams(sessionID, task.model)
|
||||
|
||||
const resumeBody = {
|
||||
agent: task.agent,
|
||||
@@ -308,10 +309,10 @@ export async function resumeTask(
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: task.sessionId },
|
||||
promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: resumeBody,
|
||||
}).catch(async (error) => {
|
||||
}, directory).catch(async (error) => {
|
||||
if (isAgentNotFoundError(error) && task.agent !== FALLBACK_AGENT) {
|
||||
log("[background-agent] Resume agent not found, retrying with fallback agent", {
|
||||
original: task.agent,
|
||||
@@ -319,12 +320,12 @@ export async function resumeTask(
|
||||
taskId: task.id,
|
||||
})
|
||||
try {
|
||||
await promptWithModelSuggestionRetry(client, {
|
||||
path: { id: task.sessionId! },
|
||||
await promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
|
||||
includeTeamToolDenylist: task.teamRunId === undefined,
|
||||
}),
|
||||
})
|
||||
}, directory)
|
||||
task.agent = FALLBACK_AGENT
|
||||
return
|
||||
} catch (retryError) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_MAX_SUBAGENT_DEPTH,
|
||||
createSubagentDepthLimitError,
|
||||
} from "./subagent-spawn-limits"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient {
|
||||
return {
|
||||
@@ -20,14 +21,14 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
test("passes query.directory to each session.get call", async () => {
|
||||
// given
|
||||
const sessionGetCalls: Array<Record<string, unknown>> = []
|
||||
const client = createMockClient((async (input) => {
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (input) => {
|
||||
sessionGetCalls.push(input as Record<string, unknown>)
|
||||
if (input.path.id === "child-session") {
|
||||
return { data: { id: "child-session", parentID: "root-session" } }
|
||||
}
|
||||
|
||||
return { data: { id: "root-session", parentID: undefined } }
|
||||
}) as unknown as OpencodeClient["session"]["get"])
|
||||
})))
|
||||
|
||||
// when
|
||||
const result = await resolveSubagentSpawnContext(client, "child-session", "/project/root")
|
||||
@@ -50,10 +51,10 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
describe("#given session.get returns an SDK error response", () => {
|
||||
test("throws a fail-closed spawn blocked error", async () => {
|
||||
// given
|
||||
const client = createMockClient((async () => ({
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async () => ({
|
||||
error: "lookup failed",
|
||||
data: undefined,
|
||||
})) as unknown as OpencodeClient["session"]["get"])
|
||||
}))))
|
||||
|
||||
// when
|
||||
const result = resolveSubagentSpawnContext(client, "parent-session")
|
||||
@@ -66,9 +67,9 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
describe("#given session.get returns no session data", () => {
|
||||
test("throws a fail-closed spawn blocked error", async () => {
|
||||
// given
|
||||
const client = createMockClient((async () => ({
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async () => ({
|
||||
data: undefined,
|
||||
})) as unknown as OpencodeClient["session"]["get"])
|
||||
}))))
|
||||
|
||||
// when
|
||||
const result = resolveSubagentSpawnContext(client, "parent-session")
|
||||
@@ -81,12 +82,12 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
describe("depth calculation smoke tests (regression guard)", () => {
|
||||
test("root session (no parentID) reports depth 0 and childDepth 1", async () => {
|
||||
// given - a root session with no parent
|
||||
const client = createMockClient((async (opts) => {
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
|
||||
if (opts.path.id === "root-session") {
|
||||
return { data: { id: "root-session", parentID: undefined } }
|
||||
}
|
||||
return { error: "not found", data: undefined }
|
||||
}) as unknown as OpencodeClient["session"]["get"])
|
||||
})))
|
||||
|
||||
// when
|
||||
const result = await resolveSubagentSpawnContext(client, "root-session")
|
||||
@@ -99,7 +100,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
|
||||
test("depth-1 child reports childDepth 2", async () => {
|
||||
// given - child -> root chain
|
||||
const client = createMockClient((async (opts) => {
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
|
||||
if (opts.path.id === "child-1") {
|
||||
return { data: { id: "child-1", parentID: "root-session" } }
|
||||
}
|
||||
@@ -107,7 +108,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
return { data: { id: "root-session", parentID: undefined } }
|
||||
}
|
||||
return { error: "not found", data: undefined }
|
||||
}) as unknown as OpencodeClient["session"]["get"])
|
||||
})))
|
||||
|
||||
// when
|
||||
const result = await resolveSubagentSpawnContext(client, "child-1")
|
||||
@@ -120,7 +121,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
|
||||
test("depth-2 grandchild reports childDepth 3", async () => {
|
||||
// given - grandchild -> child -> root chain
|
||||
const client = createMockClient((async (opts) => {
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
|
||||
const sessions: Record<string, { id: string; parentID?: string }> = {
|
||||
"grandchild": { id: "grandchild", parentID: "child" },
|
||||
"child": { id: "child", parentID: "root" },
|
||||
@@ -129,7 +130,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
const session = sessions[opts.path.id]
|
||||
if (session) return { data: session }
|
||||
return { error: "not found", data: undefined }
|
||||
}) as unknown as OpencodeClient["session"]["get"])
|
||||
})))
|
||||
|
||||
// when
|
||||
const result = await resolveSubagentSpawnContext(client, "grandchild")
|
||||
@@ -153,11 +154,11 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const client = createMockClient((async (opts) => {
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
|
||||
const session = sessions[opts.path.id]
|
||||
if (session) return { data: session }
|
||||
return { error: "not found", data: undefined }
|
||||
}) as unknown as OpencodeClient["session"]["get"])
|
||||
})))
|
||||
|
||||
// when - resolve from the deepest session
|
||||
const deepest = `session-${DEFAULT_MAX_SUBAGENT_DEPTH}`
|
||||
@@ -170,7 +171,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
|
||||
test("detects parent cycle and throws", async () => {
|
||||
// given - A -> B -> A (cycle)
|
||||
const client = createMockClient((async (opts) => {
|
||||
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
|
||||
const sessions: Record<string, { id: string; parentID?: string }> = {
|
||||
"session-a": { id: "session-a", parentID: "session-b" },
|
||||
"session-b": { id: "session-b", parentID: "session-a" },
|
||||
@@ -178,7 +179,7 @@ describe("resolveSubagentSpawnContext", () => {
|
||||
const session = sessions[opts.path.id]
|
||||
if (session) return { data: session }
|
||||
return { error: "not found", data: undefined }
|
||||
}) as unknown as OpencodeClient["session"]["get"])
|
||||
})))
|
||||
|
||||
// when
|
||||
const result = resolveSubagentSpawnContext(client, "session-a")
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
||||
|
||||
type PromptAsyncCall = {
|
||||
path: { id: string }
|
||||
@@ -11,6 +12,9 @@ type PromptAsyncCall = {
|
||||
noReply?: boolean
|
||||
parts?: unknown[]
|
||||
}
|
||||
query?: {
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
type FakeTimers = {
|
||||
@@ -159,14 +163,6 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back
|
||||
return notifyParentSession.call(manager, task)
|
||||
}
|
||||
|
||||
function waitForDeferredWake(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 180))
|
||||
}
|
||||
|
||||
function waitForDeferredWakeRetry(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1_180))
|
||||
}
|
||||
|
||||
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
|
||||
const timer = getCompletionTimers(manager).get(taskID)
|
||||
expect(timer).toBeDefined()
|
||||
@@ -241,6 +237,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(2)
|
||||
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
|
||||
expect(getCompletionTimers(manager).size).toBe(2)
|
||||
const allCompleteCall = promptAsyncCalls[1]
|
||||
expect(allCompleteCall).toBeDefined()
|
||||
@@ -251,13 +248,14 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
expect(allCompleteCall.body.noReply).toBe(false)
|
||||
const allCompletePayload = JSON.stringify(allCompleteCall.body.parts)
|
||||
expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(allCompletePayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
|
||||
expect(allCompletePayload).toContain(taskA.id)
|
||||
expect(allCompletePayload).toContain(taskB.id)
|
||||
expect(allCompletePayload).toContain(taskA.description)
|
||||
expect(allCompletePayload).toContain(taskB.description)
|
||||
})
|
||||
|
||||
test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => {
|
||||
test("#when parent session is busy #then all-complete notification keeps the direct 4.0.0 parent prompt behavior", async () => {
|
||||
// given
|
||||
const sessionStatuses: Record<string, { type: string }> = {
|
||||
"parent-1": { type: "busy" },
|
||||
@@ -272,10 +270,32 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(0)
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
|
||||
const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
|
||||
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(notificationPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
|
||||
})
|
||||
|
||||
test("#when deferred parent session becomes idle #then completion notification wakes the parent without a pointer reminder", async () => {
|
||||
test("#when all-complete notification wakes parent #then prompt stays in the same OpenCode directory instance", async () => {
|
||||
// given
|
||||
const { manager, promptAsyncCalls } = createManager(true)
|
||||
managerUnderTest = manager
|
||||
const directory = Reflect.get(manager, "directory") as string
|
||||
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
|
||||
getTasks(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
|
||||
|
||||
// when
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
|
||||
expect(promptAsyncCalls[0]?.query).toEqual({ directory })
|
||||
})
|
||||
|
||||
test("#when busy parent later becomes idle #then completion notification is not replayed as a second parent prompt", async () => {
|
||||
// given
|
||||
const sessionStatuses: Record<string, { type: string }> = {
|
||||
"parent-1": { type: "busy" },
|
||||
@@ -286,21 +306,22 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
getTasks(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
|
||||
// when
|
||||
sessionStatuses["parent-1"] = { type: "idle" }
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
|
||||
await waitForDeferredWake()
|
||||
await Promise.resolve()
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
|
||||
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
|
||||
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
|
||||
const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
|
||||
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(notificationPayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
|
||||
})
|
||||
|
||||
test("#when a single background task finishes during a stale busy parent status #then completion notification is retried after the parent becomes idle", async () => {
|
||||
test("#when a single background task finishes during a stale busy parent status #then no deferred wake is scheduled", async () => {
|
||||
// given
|
||||
const sessionStatuses: Record<string, { type: string }> = {
|
||||
"parent-1": { type: "busy" },
|
||||
@@ -314,22 +335,23 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
// when
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
sessionStatuses["parent-1"] = { type: "idle" }
|
||||
await waitForDeferredWakeRetry()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_180))
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
|
||||
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
|
||||
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
|
||||
const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
|
||||
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(notificationPayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
|
||||
})
|
||||
|
||||
test("#when deferred completion notification send fails #then notification is queued for the next user message", async () => {
|
||||
test("#when completion notification send is aborted #then notification is queued for the next user message", async () => {
|
||||
// given
|
||||
const sessionStatuses: Record<string, { type: string }> = {
|
||||
"parent-1": { type: "busy" },
|
||||
}
|
||||
const promptError = new Error("promptAsync failed")
|
||||
const promptError = new Error("Request aborted while waiting for input")
|
||||
promptError.name = "MessageAbortedError"
|
||||
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, async () => {
|
||||
throw promptError
|
||||
})
|
||||
@@ -337,12 +359,9 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
|
||||
getTasks(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
|
||||
// when
|
||||
sessionStatuses["parent-1"] = { type: "idle" }
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
|
||||
await waitForDeferredWake()
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { transformMcpServer } from "./transformer"
|
||||
import { log } from "../../shared/logger"
|
||||
import { shouldLoadMcpServer } from "./scope-filter"
|
||||
import { bunFile } from "../../shared/bun-file-shim"
|
||||
|
||||
interface McpConfigPath {
|
||||
path: string
|
||||
@@ -37,7 +38,7 @@ async function loadMcpConfigFile(
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(filePath).text()
|
||||
const content = await bunFile(filePath).text()
|
||||
return JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
} catch (error) {
|
||||
log(`Failed to load MCP config from ${filePath}`, error)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { LoadedPlugin } from "./types"
|
||||
import { resolvePluginPaths } from "./plugin-path-resolver"
|
||||
import { bunFile } from "../../shared/bun-file-shim"
|
||||
|
||||
export async function loadPluginMcpServers(
|
||||
plugins: LoadedPlugin[],
|
||||
@@ -18,7 +19,7 @@ export async function loadPluginMcpServers(
|
||||
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
|
||||
|
||||
try {
|
||||
const content = await Bun.file(plugin.mcpPath).text()
|
||||
const content = await bunFile(plugin.mcpPath).text()
|
||||
let config = JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
|
||||
config = resolvePluginPaths(config, plugin.installPath)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ContextCollector } from "./collector"
|
||||
import {
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./injector"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("createContextInjectorMessagesTransformHook", () => {
|
||||
let collector: ContextCollector
|
||||
@@ -51,7 +52,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
createMockMessage("user", "Second message", sessionID),
|
||||
]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
const output = unsafeTestValue({ messages })
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
@@ -115,7 +116,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
const sessionID = "ses_transform2"
|
||||
const messages = [createMockMessage("user", "Hello world", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
const output = unsafeTestValue({ messages })
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
@@ -135,7 +136,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
})
|
||||
const messages = [createMockMessage("assistant", "Response", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
const output = unsafeTestValue({ messages })
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
@@ -156,7 +157,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
})
|
||||
const messages = [createMockMessage("user", "Message", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
const output = unsafeTestValue({ messages })
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
@@ -79,6 +79,14 @@ type MessagesTransformHook = {
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
function getSessionIDFromMessageInfo(info: Message): string | undefined {
|
||||
return "sessionID" in info && typeof info.sessionID === "string" ? info.sessionID : undefined
|
||||
}
|
||||
|
||||
function hasText(part: Part): boolean {
|
||||
return "text" in part && typeof part.text === "string" && part.text.length > 0
|
||||
}
|
||||
|
||||
export function createContextInjectorMessagesTransformHook(
|
||||
collector: ContextCollector
|
||||
): MessagesTransformHook {
|
||||
@@ -106,8 +114,7 @@ export function createContextInjectorMessagesTransformHook(
|
||||
}
|
||||
|
||||
const lastUserMessage = messages[lastUserMessageIndex]
|
||||
// Try message.info.sessionID first, fallback to mainSessionID
|
||||
const messageSessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID
|
||||
const messageSessionID = getSessionIDFromMessageInfo(lastUserMessage.info)
|
||||
const sessionID = messageSessionID ?? getMainSessionID()
|
||||
log("[DEBUG] Extracted sessionID", {
|
||||
messageSessionID,
|
||||
@@ -135,7 +142,7 @@ export function createContextInjectorMessagesTransformHook(
|
||||
}
|
||||
|
||||
const textPartIndex = lastUserMessage.parts.findIndex(
|
||||
(p) => p.type === "text" && (p as { text?: string }).text
|
||||
(p) => p.type === "text" && hasText(p)
|
||||
)
|
||||
|
||||
if (textPartIndex === -1) {
|
||||
@@ -150,7 +157,7 @@ export function createContextInjectorMessagesTransformHook(
|
||||
const syntheticPart = {
|
||||
id: `synthetic_hook_${sessionID}`,
|
||||
messageID: lastUserMessage.info.id,
|
||||
sessionID: (lastUserMessage.info as { sessionID?: string }).sessionID ?? "",
|
||||
sessionID: messageSessionID ?? "",
|
||||
type: "text" as const,
|
||||
text: pending.merged,
|
||||
synthetic: true, // hidden in UI
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
injectHookMessage,
|
||||
} from "./injector"
|
||||
import { getCompactionPartStorageDir } from "../../shared/compaction-marker"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
//#region Mocks
|
||||
|
||||
@@ -73,7 +74,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
{ info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" } } },
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toEqual({
|
||||
agent: "sisyphus",
|
||||
@@ -87,7 +88,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
{ info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } },
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toEqual({
|
||||
agent: "sisyphus",
|
||||
@@ -102,7 +103,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
{ id: "msg_new", info: { agent: "new-agent", model: { providerID: "new", modelID: "model" }, time: { created: 20 } } },
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result?.agent).toBe("new-agent")
|
||||
})
|
||||
@@ -112,7 +113,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
{ info: { agent: "partial-agent" } },
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result?.agent).toBe("partial-agent")
|
||||
})
|
||||
@@ -123,7 +124,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
{ info: {} },
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
@@ -131,7 +132,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
it("returns null when messages array is empty", async () => {
|
||||
const mockClient = createMockClient([])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
@@ -145,7 +146,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
@@ -161,7 +162,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
},
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result?.tools).toEqual({ edit: true, write: false })
|
||||
})
|
||||
@@ -172,7 +173,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
{ id: "msg_older", info: { agent: "newest-by-time", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 100 } } },
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result?.agent).toBe("newest-by-time")
|
||||
})
|
||||
@@ -190,7 +191,7 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
||||
},
|
||||
])
|
||||
|
||||
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findNearestMessageWithFieldsFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
@@ -252,7 +253,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
{ info: { agent: "second-agent" } },
|
||||
])
|
||||
|
||||
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBe("first-agent")
|
||||
})
|
||||
@@ -263,7 +264,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
{ id: "msg_early", info: { agent: "earliest-agent", time: { created: 10 } } },
|
||||
])
|
||||
|
||||
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBe("earliest-agent")
|
||||
})
|
||||
@@ -274,7 +275,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } },
|
||||
])
|
||||
|
||||
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBe("sisyphus")
|
||||
})
|
||||
@@ -285,7 +286,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
{ info: { agent: "first-real-agent" } },
|
||||
])
|
||||
|
||||
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBe("first-real-agent")
|
||||
})
|
||||
@@ -296,7 +297,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
{ info: {} },
|
||||
])
|
||||
|
||||
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
@@ -310,7 +311,7 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||
const result = await findFirstMessageWithAgentFromSDK(unsafeTestValue(mockClient), "ses_123")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
|
||||
|
||||
import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils"
|
||||
|
||||
const DEFAULT_PORT = 19877
|
||||
@@ -51,56 +53,73 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
rejectCallback?.(new Error("OAuth callback timed out after 5 minutes"))
|
||||
server.stop(true)
|
||||
server.close()
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
const server = Bun.serve({
|
||||
port: requestedPort,
|
||||
hostname: "127.0.0.1",
|
||||
fetch(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
|
||||
if (url.pathname !== "/oauth/callback") {
|
||||
return new Response("Not Found", { status: 404 })
|
||||
}
|
||||
if (url.pathname !== "/oauth/callback") {
|
||||
response.statusCode = 404
|
||||
response.end("Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
const oauthError = url.searchParams.get("error")
|
||||
if (oauthError) {
|
||||
const description = url.searchParams.get("error_description") ?? oauthError
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error(`OAuth authorization failed: ${description}`))
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
return new Response(`Authorization failed: ${description}`, { status: 400 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
|
||||
if (!code || !state) {
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error("OAuth callback missing code or state parameter"))
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
return new Response("Missing code or state parameter", { status: 400 })
|
||||
}
|
||||
|
||||
resolveCallback?.({ code, state })
|
||||
const oauthError = url.searchParams.get("error")
|
||||
if (oauthError) {
|
||||
const description = url.searchParams.get("error_description") ?? oauthError
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error(`OAuth authorization failed: ${description}`))
|
||||
response.statusCode = 400
|
||||
response.end(`Authorization failed: ${description}`)
|
||||
setTimeout(() => server.close(), 100)
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
|
||||
return new Response(SUCCESS_HTML, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
})
|
||||
},
|
||||
if (!code || !state) {
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error("OAuth callback missing code or state parameter"))
|
||||
response.statusCode = 400
|
||||
response.end("Missing code or state parameter")
|
||||
setTimeout(() => server.close(), 100)
|
||||
return
|
||||
}
|
||||
|
||||
resolveCallback?.({ code, state })
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
response.statusCode = 200
|
||||
response.setHeader("content-type", "text/html; charset=utf-8")
|
||||
response.end(SUCCESS_HTML)
|
||||
setTimeout(() => server.close(), 100)
|
||||
})
|
||||
const activePort = server.port ?? requestedPort
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const handleError = (error: Error): void => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
server.once("error", handleError)
|
||||
server.once("listening", () => {
|
||||
server.off("error", handleError)
|
||||
resolve()
|
||||
})
|
||||
server.listen(requestedPort, "127.0.0.1")
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
const activePort = typeof address === "object" && address !== null ? address.port : requestedPort
|
||||
|
||||
return {
|
||||
port: activePort,
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => {
|
||||
clearTimeout(timeoutId)
|
||||
server.stop(true)
|
||||
server.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { OAuthTokenData } from "../mcp-oauth/storage"
|
||||
import { setHttpClientDependenciesForTesting } from "./http-client"
|
||||
import { setStdioClientDependenciesForTesting } from "./stdio-client"
|
||||
import { SkillMcpManager } from "./manager"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
||||
const mockHttpClose = mock(() => Promise.resolve())
|
||||
@@ -634,7 +635,7 @@ describe("SkillMcpManager", () => {
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// when
|
||||
@@ -668,7 +669,7 @@ describe("SkillMcpManager", () => {
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// when / #then
|
||||
@@ -700,7 +701,7 @@ describe("SkillMcpManager", () => {
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// when / #then
|
||||
@@ -929,7 +930,7 @@ describe("SkillMcpManager", () => {
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// when
|
||||
@@ -962,7 +963,7 @@ describe("SkillMcpManager", () => {
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// when / #then
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test")
|
||||
import type { ConcurrencyManager } from "../background-agent/concurrency"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
type TaskToastManagerClass = typeof import("./manager").TaskToastManager
|
||||
|
||||
@@ -20,15 +21,15 @@ describe("TaskToastManager", () => {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
mockConcurrencyManager = {
|
||||
mockConcurrencyManager = unsafeTestValue<ConcurrencyManager>({
|
||||
getConcurrencyLimit: mock(() => 5),
|
||||
} as unknown as ConcurrencyManager
|
||||
})
|
||||
|
||||
const mod = await import("./manager")
|
||||
TaskToastManager = mod.TaskToastManager
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
toastManager = new TaskToastManager(mockClient as any, mockConcurrencyManager)
|
||||
toastManager = new TaskToastManager(unsafeTestValue(mockClient), mockConcurrencyManager)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,14 +109,14 @@ describe("TaskToastManager", () => {
|
||||
|
||||
test("should display concurrency limit info when available", () => {
|
||||
// given - a concurrency manager with known limit
|
||||
const mockConcurrencyWithCounts = {
|
||||
const mockConcurrencyWithCounts = unsafeTestValue<ConcurrencyManager>({
|
||||
getConcurrencyLimit: mock(() => 5),
|
||||
getRunningCount: mock(() => 2),
|
||||
getQueuedCount: mock(() => 1),
|
||||
} as unknown as ConcurrencyManager
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const managerWithConcurrency = new TaskToastManager(mockClient as any, mockConcurrencyWithCounts)
|
||||
const managerWithConcurrency = new TaskToastManager(unsafeTestValue(mockClient), mockConcurrencyWithCounts)
|
||||
|
||||
// when - a task is added
|
||||
managerWithConcurrency.addTask({
|
||||
@@ -357,11 +358,11 @@ describe("TaskToastManager", () => {
|
||||
|
||||
test("should show model name in queued tasks too", () => {
|
||||
// given - a concurrency manager that limits to 1
|
||||
const limitedConcurrency = {
|
||||
const limitedConcurrency = unsafeTestValue<ConcurrencyManager>({
|
||||
getConcurrencyLimit: mock(() => 1),
|
||||
} as unknown as ConcurrencyManager
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const limitedManager = new TaskToastManager(mockClient as any, limitedConcurrency)
|
||||
const limitedManager = new TaskToastManager(unsafeTestValue(mockClient), limitedConcurrency)
|
||||
|
||||
limitedManager.addTask({
|
||||
id: "task_running",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { spawn } from "../../shared/bun-spawn-shim"
|
||||
|
||||
export interface TeamModeDependencyReport {
|
||||
tmuxAvailable: boolean
|
||||
@@ -20,7 +21,7 @@ export async function checkTeamModeDependencies(
|
||||
|
||||
async function probeBinary(cmd: string, args: string[]): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" })
|
||||
const proc = spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
} catch {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { saveRuntimeState } from "../team-state-store/store"
|
||||
import type { RuntimeState } from "../types"
|
||||
import { cleanupTeamRunResources } from "./cleanup-team-run-resources"
|
||||
import { unsafeTestValue } from "../../../../test-support/unsafe-test-value"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
@@ -41,9 +42,9 @@ function createRuntimeState(teamRunId: string): RuntimeState {
|
||||
}
|
||||
|
||||
function createStubBgMgr(): BackgroundManager {
|
||||
return {
|
||||
return unsafeTestValue<BackgroundManager>({
|
||||
cancelTask: async () => undefined,
|
||||
} as unknown as BackgroundManager
|
||||
})
|
||||
}
|
||||
|
||||
describe("cleanupTeamRunResources", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
||||
import * as sharedModule from "../../shared"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import {
|
||||
isInsideTmux as defaultIsInsideTmux,
|
||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||
@@ -1098,9 +1099,9 @@ export class TmuxSessionManager {
|
||||
if (event.type !== "session.created") return
|
||||
|
||||
const info = event.properties?.info
|
||||
if (!info?.id || !info?.parentID) return
|
||||
const sessionId = resolveSessionEventID(event.properties)
|
||||
if (!sessionId || !info?.parentID) return
|
||||
|
||||
const sessionId = info.id
|
||||
const title = info.title ?? "Subagent"
|
||||
|
||||
if (!this.sourcePaneId) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
import type { TrackedSession } from "./types"
|
||||
|
||||
describe("TmuxPollingManager event session ids", () => {
|
||||
test("#given legacy message.part.updated properties #when handling activity #then part session id increments activity version", () => {
|
||||
const sessions = new Map<string, TrackedSession>()
|
||||
sessions.set("ses-part-only", {
|
||||
sessionId: "ses-part-only",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
createdAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
closePending: false,
|
||||
closeRetryCount: 0,
|
||||
activityVersion: 0,
|
||||
})
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new TmuxPollingManager(client as never, sessions, async () => {})
|
||||
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "ses-part-only",
|
||||
type: "text",
|
||||
text: "working",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(sessions.get("ses-part-only")?.activityVersion).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
import type { TrackedSession } from "./types"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("TmuxPollingManager overlap", () => {
|
||||
test("skips overlapping pollSessions executions", async () => {
|
||||
@@ -39,15 +40,15 @@ describe("TmuxPollingManager overlap", () => {
|
||||
}
|
||||
|
||||
const manager = new TmuxPollingManager(
|
||||
client as unknown as import("../../tools/delegate-task/types").OpencodeClient,
|
||||
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||
sessions,
|
||||
async () => {},
|
||||
)
|
||||
|
||||
//#when
|
||||
const firstPoll = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions()
|
||||
const firstPoll = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions()
|
||||
await Promise.resolve()
|
||||
const secondPoll = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions()
|
||||
const secondPoll = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions()
|
||||
releaseStatus?.()
|
||||
await Promise.all([firstPoll, secondPoll])
|
||||
|
||||
@@ -85,7 +86,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
}
|
||||
|
||||
const manager = new TmuxPollingManager(
|
||||
client as unknown as import("../../tools/delegate-task/types").OpencodeClient,
|
||||
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||
sessions,
|
||||
async (sessionId) => {
|
||||
closedSessionIds.push(sessionId)
|
||||
@@ -98,7 +99,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
})
|
||||
|
||||
//#when
|
||||
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions
|
||||
const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
|
||||
await pollSessions.call(manager)
|
||||
await pollSessions.call(manager)
|
||||
await pollSessions.call(manager)
|
||||
@@ -132,7 +133,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
}
|
||||
|
||||
const manager = new TmuxPollingManager(
|
||||
client as unknown as import("../../tools/delegate-task/types").OpencodeClient,
|
||||
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||
sessions,
|
||||
async (sessionId) => {
|
||||
closedSessionIds.push(sessionId)
|
||||
@@ -140,7 +141,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
)
|
||||
|
||||
// when
|
||||
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions
|
||||
const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
|
||||
await pollSessions.call(manager)
|
||||
|
||||
// then
|
||||
@@ -171,7 +172,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
}
|
||||
|
||||
const manager = new TmuxPollingManager(
|
||||
client as unknown as import("../../tools/delegate-task/types").OpencodeClient,
|
||||
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||
sessions,
|
||||
async (sessionId) => {
|
||||
closedSessionIds.push(sessionId)
|
||||
@@ -179,7 +180,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
)
|
||||
|
||||
// when
|
||||
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions
|
||||
const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
|
||||
await pollSessions.call(manager)
|
||||
|
||||
// then
|
||||
@@ -222,13 +223,13 @@ describe("TmuxPollingManager overlap", () => {
|
||||
}
|
||||
|
||||
manager = new TmuxPollingManager(
|
||||
client as unknown as import("../../tools/delegate-task/types").OpencodeClient,
|
||||
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||
sessions,
|
||||
async (sessionId) => {
|
||||
closedSessionIds.push(sessionId)
|
||||
},
|
||||
)
|
||||
const pollSessions = (manager as unknown as { pollSessions: () => Promise<void> }).pollSessions
|
||||
const pollSessions = (unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager)).pollSessions
|
||||
|
||||
// when
|
||||
await pollSessions.call(manager)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { TrackedSession } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||
|
||||
const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
const STABLE_POLLS_REQUIRED = 3
|
||||
@@ -170,10 +171,7 @@ export class TmuxPollingManager {
|
||||
if (!properties) return undefined
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = properties.info
|
||||
if (!info || typeof info !== "object") return undefined
|
||||
const sessionId = (info as { sessionID?: unknown }).sessionID
|
||||
return typeof sessionId === "string" ? sessionId : undefined
|
||||
return resolveMessageEventSessionID(properties)
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -182,8 +180,7 @@ export class TmuxPollingManager {
|
||||
|| event.type === "message.part.removed"
|
||||
|| event.type === "message.removed"
|
||||
) {
|
||||
const sessionId = properties.sessionID
|
||||
return typeof sessionId === "string" ? sessionId : undefined
|
||||
return resolveMessageEventSessionID(properties)
|
||||
}
|
||||
|
||||
return undefined
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { CapacityConfig, TrackedSession } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
||||
import { executeActions } from "./action-executor"
|
||||
@@ -44,9 +45,9 @@ export async function handleSessionCreated(
|
||||
if (event.type !== "session.created") return
|
||||
|
||||
const info = event.properties?.info
|
||||
if (!info?.id || !info?.parentID) return
|
||||
const sessionId = resolveSessionEventID(event.properties)
|
||||
if (!sessionId || !info?.parentID) return
|
||||
|
||||
const sessionId = info.id
|
||||
const title = info.title ?? "Subagent"
|
||||
|
||||
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
|
||||
|
||||
Reference in New Issue
Block a user