Merge remote-tracking branch 'origin/dev' into fix/delegate-task-depth-guard-rebased
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
clearCompactionAgentConfigCheckpoint,
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { PART_STORAGE } from "../../shared"
|
||||
|
||||
describe("isCompactionAgent", () => {
|
||||
describe("#given agent name variations", () => {
|
||||
@@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { force: true, recursive: true })
|
||||
rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true })
|
||||
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
||||
})
|
||||
|
||||
@@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("skips JSON messages whose part storage contains a compaction marker", () => {
|
||||
// given
|
||||
const compactionMessageID = "msg_test_background_compaction_marker"
|
||||
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify({
|
||||
id: compactionMessageID,
|
||||
agent: "atlas",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}))
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify({
|
||||
id: "msg_001",
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}))
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("falls back to partial agent/model match", () => {
|
||||
// given
|
||||
const messageWithAgentOnly = {
|
||||
@@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => {
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("skips SDK messages that only exist to mark compaction", () => {
|
||||
// given
|
||||
const messages = [
|
||||
{
|
||||
id: "msg_compaction",
|
||||
info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" } },
|
||||
parts: [{ type: "compaction" }],
|
||||
},
|
||||
{ info: { agent: "sisyphus" } },
|
||||
{ info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } },
|
||||
{ info: { tools: { bash: true } } },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = resolvePromptContextFromSessionMessages(messages)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,16 @@ import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { StoredMessage } from "../hook-message-injector"
|
||||
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import {
|
||||
hasCompactionPartInStorage,
|
||||
isCompactionAgent,
|
||||
isCompactionMessage,
|
||||
} from "../../shared/compaction-marker"
|
||||
|
||||
export { isCompactionAgent } from "../../shared/compaction-marker"
|
||||
|
||||
type SessionMessage = {
|
||||
id?: string
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
@@ -15,10 +23,7 @@ type SessionMessage = {
|
||||
modelID?: string
|
||||
tools?: StoredMessage["tools"]
|
||||
}
|
||||
}
|
||||
|
||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
parts?: Array<{ type?: string }>
|
||||
}
|
||||
|
||||
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
||||
@@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean {
|
||||
}
|
||||
|
||||
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
||||
if (isCompactionMessage(message)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = message.info
|
||||
if (!info) {
|
||||
return null
|
||||
@@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction(
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
messages.push(JSON.parse(content) as StoredMessage)
|
||||
const parsed = JSON.parse(content) as StoredMessage & { id?: string }
|
||||
if (hasCompactionPartInStorage(parsed.id) || isCompactionAgent(parsed.agent)) {
|
||||
continue
|
||||
}
|
||||
messages.push(parsed)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -218,6 +218,10 @@ function getRootDescendantCounts(manager: BackgroundManager): Map<string, number
|
||||
return (manager as unknown as { rootDescendantCounts: Map<string, number> }).rootDescendantCounts
|
||||
}
|
||||
|
||||
function getPreStartDescendantReservations(manager: BackgroundManager): Set<string> {
|
||||
return (manager as unknown as { preStartDescendantReservations: Set<string> }).preStartDescendantReservations
|
||||
}
|
||||
|
||||
function getQueuesByKey(
|
||||
manager: BackgroundManager
|
||||
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
||||
@@ -1144,7 +1148,18 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
messages: async () => ({
|
||||
data: [{
|
||||
info: {
|
||||
agent: "explore",
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
variant: "high",
|
||||
},
|
||||
},
|
||||
}],
|
||||
}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
@@ -1177,6 +1192,101 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
test("should prefer parent session variant over child task variant in parent notification promptAsync body", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push({ body: args.body })
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({
|
||||
data: [{
|
||||
info: {
|
||||
agent: "explore",
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
}],
|
||||
}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-parent-variant-wins",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task with mismatched variant",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "high" },
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.variant).toBe("max")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should not include variant in promptAsync body when task has no variant", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push({ body: args.body })
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-variant",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task without variant",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.variant).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
|
||||
test("should prepend queued notifications to first text part and clear queue", () => {
|
||||
// given
|
||||
@@ -1437,6 +1547,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
const task = createMockTask({
|
||||
id: "task-zombie-session",
|
||||
sessionID: "session-zombie-placeholder",
|
||||
parentSessionID: "parent-zombie",
|
||||
status: "pending",
|
||||
agent: "explore",
|
||||
@@ -1779,10 +1890,10 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
expect(getSessionPromptParams("session-advanced")).toEqual({
|
||||
temperature: 0.25,
|
||||
topP: 0.55,
|
||||
maxOutputTokens: 8192,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 8192,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -2379,6 +2490,46 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(retryTask.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("should only roll back the failed task reservation once when siblings still exist", async () => {
|
||||
// given
|
||||
const concurrencyKey = "test-agent"
|
||||
const task = createMockTask({
|
||||
id: "task-single-reservation-rollback",
|
||||
sessionID: "session-single-reservation-rollback",
|
||||
parentSessionID: "session-root",
|
||||
status: "pending",
|
||||
agent: "test-agent",
|
||||
rootSessionID: "session-root",
|
||||
})
|
||||
delete (task as Partial<BackgroundTask>).sessionID
|
||||
|
||||
const input = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||
getRootDescendantCounts(manager).set("session-root", 2)
|
||||
getPreStartDescendantReservations(manager).add(task.id)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
;(manager as unknown as {
|
||||
startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void>
|
||||
}).startTask = async () => {
|
||||
throw new Error("session create failed")
|
||||
}
|
||||
|
||||
// when
|
||||
await processKeyForTest(manager, concurrencyKey)
|
||||
|
||||
// then
|
||||
expect(getRootDescendantCounts(manager).get("session-root")).toBe(1)
|
||||
})
|
||||
|
||||
test("should keep the next queued task when the first task is cancelled during session creation", async () => {
|
||||
// given
|
||||
const firstSessionID = "ses-first-cancelled-during-create"
|
||||
|
||||
@@ -422,10 +422,6 @@ export class BackgroundManager {
|
||||
this.concurrencyManager.release(key)
|
||||
}
|
||||
|
||||
if (item.task.rootSessionID) {
|
||||
this.unregisterRootDescendant(item.task.rootSessionID)
|
||||
}
|
||||
|
||||
removeTaskToastTracking(item.task.id)
|
||||
|
||||
// Abort the orphaned session if one was created before the error
|
||||
@@ -1783,6 +1779,7 @@ export class BackgroundManager {
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
let tools: Record<string, boolean> | undefined = task.parentTools
|
||||
let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
|
||||
|
||||
if (this.enableParentSessionNotifications) {
|
||||
try {
|
||||
@@ -1796,7 +1793,7 @@ export class BackgroundManager {
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}>)
|
||||
const promptContext = resolvePromptContextFromSessionMessages(
|
||||
promptContext = resolvePromptContextFromSessionMessages(
|
||||
messages,
|
||||
task.parentSessionID,
|
||||
)
|
||||
@@ -1840,6 +1837,8 @@ export class BackgroundManager {
|
||||
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
|
||||
const shouldReply = allComplete || isTaskFailure
|
||||
|
||||
const variant = promptContext?.model?.variant
|
||||
|
||||
try {
|
||||
await this.client.session.promptAsync({
|
||||
path: { id: task.parentSessionID },
|
||||
@@ -1847,6 +1846,7 @@ export class BackgroundManager {
|
||||
noReply: !shouldReply,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(variant !== undefined ? { variant } : {}),
|
||||
...(resolvedTools ? { tools: resolvedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(notification)],
|
||||
},
|
||||
|
||||
@@ -400,10 +400,10 @@ describe("background-agent spawner fallback model promotion", () => {
|
||||
expect(getSessionPromptParams("session-123")).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => {
|
||||
})
|
||||
expect(promptCalls[0]?.body?.variant).toBe("medium")
|
||||
})
|
||||
|
||||
test("strips leading zwsp from prompt body agent before promptAsync", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ body?: { agent?: string } }> = []
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent/dir" } }),
|
||||
create: async () => ({ data: { id: "ses_child_clean_agent" } }),
|
||||
promptAsync: async (args?: { body?: { agent?: string } }) => {
|
||||
promptCalls.push(args ?? {})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const task = createTask({
|
||||
description: "Test task",
|
||||
prompt: "Do work",
|
||||
agent: "\u200Bsisyphus-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,
|
||||
},
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
client,
|
||||
directory: "/fallback",
|
||||
concurrencyManager: { release: () => {} },
|
||||
tmuxEnabled: false,
|
||||
onTaskError: () => {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await startTask(item as any, ctx as any)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
export const FALLBACK_AGENT = "general"
|
||||
@@ -168,11 +169,12 @@ export async function startTask(
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
const normalizedAgent = stripAgentListSortPrefix(input.agent)
|
||||
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
|
||||
const promptBody = {
|
||||
agent: input.agent,
|
||||
agent: normalizedAgent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
@@ -180,7 +182,7 @@ export async function startTask(
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
...getAgentToolRestrictions(normalizedAgent),
|
||||
},
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user