feat(athena): council member continuation enforcer, tool allowlist, and prompt fixes
- Add council-continuation-enforcer to nudge council members until they output COUNCIL_MEMBER_RESPONSE, replacing the hacky post-compaction hack - Add background_wait + background_cancel to council member tool allowlist so members can properly manage their explore agents before responding - Update COUNCIL_DELEGATION_ADDENDUM prompt to instruct members to cancel pending tasks and wait for explore results before final response - Fix council_finalize path resolution to use project directory - Remove post-compaction-continuation.ts and recentlyCompactedSessions hack - Add council-response-checker for detecting response tag in session messages
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
|
||||
import {
|
||||
isCouncilMemberAgent,
|
||||
hasCouncilResponseTag,
|
||||
sendCouncilContinuationNudge,
|
||||
resetCouncilNudgeCount,
|
||||
} from "./council-continuation-enforcer"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
sessionID: "ses-council-1",
|
||||
parentSessionID: "parent-ses-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "council test",
|
||||
prompt: "test",
|
||||
agent: "Council: deep-1",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
progress: { lastUpdate: new Date(), toolCalls: 0 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockClient() {
|
||||
return { session: { promptAsync: mock(() => Promise.resolve()) } }
|
||||
}
|
||||
|
||||
describe("isCouncilMemberAgent", () => {
|
||||
describe('#given agent name starts with "Council: "', () => {
|
||||
it("#then should return true", () => {
|
||||
//#given
|
||||
const agentName = "Council: deep-1"
|
||||
|
||||
//#when
|
||||
const result = isCouncilMemberAgent(agentName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when name has only the prefix #then should return true", () => {
|
||||
//#given
|
||||
const agentName = "Council: "
|
||||
|
||||
//#when
|
||||
const result = isCouncilMemberAgent(agentName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given agent name does not start with the council prefix", () => {
|
||||
it('#when name is "explore" #then should return false', () => {
|
||||
//#given
|
||||
const agentName = "explore"
|
||||
|
||||
//#when
|
||||
const result = isCouncilMemberAgent(agentName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('#when name is "oracle" #then should return false', () => {
|
||||
//#given
|
||||
const agentName = "oracle"
|
||||
|
||||
//#when
|
||||
const result = isCouncilMemberAgent(agentName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('#when name is "sisyphus" #then should return false', () => {
|
||||
//#given
|
||||
const agentName = "sisyphus"
|
||||
|
||||
//#when
|
||||
const result = isCouncilMemberAgent(agentName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("#when name is empty string #then should return false", () => {
|
||||
//#given
|
||||
const agentName = ""
|
||||
|
||||
//#when
|
||||
const result = isCouncilMemberAgent(agentName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given agent name is undefined", () => {
|
||||
it("#then should return false", () => {
|
||||
//#when
|
||||
const result = isCouncilMemberAgent(undefined)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasCouncilResponseTag", () => {
|
||||
describe("#given empty messages array", () => {
|
||||
it("#then should return false", () => {
|
||||
//#when
|
||||
const result = hasCouncilResponseTag([])
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no assistant messages", () => {
|
||||
it("#then should return false", () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "user" },
|
||||
parts: [{ type: "text", text: "</COUNCIL_MEMBER_RESPONSE>" }],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = hasCouncilResponseTag(messages)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given assistant message contains the response tag", () => {
|
||||
it("#then should return true", () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "My analysis </COUNCIL_MEMBER_RESPONSE>" }],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = hasCouncilResponseTag(messages)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when tag is in the last assistant message #then should return true", () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "no tag here" }],
|
||||
},
|
||||
{
|
||||
info: { role: "user" },
|
||||
parts: [{ type: "text", text: "user message" }],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "final answer </COUNCIL_MEMBER_RESPONSE>" }],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = hasCouncilResponseTag(messages)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given assistant messages without the response tag", () => {
|
||||
it("#then should return false", () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "analysis without closing tag" }],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = hasCouncilResponseTag(messages)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user message contains the tag but no assistant message does", () => {
|
||||
it("#then should return false", () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{
|
||||
info: { role: "user" },
|
||||
parts: [{ type: "text", text: "</COUNCIL_MEMBER_RESPONSE>" }],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "no tag in assistant" }],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = hasCouncilResponseTag(messages)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given messages with missing parts", () => {
|
||||
it("#then should handle gracefully and return false", () => {
|
||||
//#given
|
||||
const messages = [
|
||||
{ info: { role: "assistant" } },
|
||||
{ info: { role: "assistant" }, parts: [] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text" }] },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = hasCouncilResponseTag(messages)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendCouncilContinuationNudge", () => {
|
||||
describe("#given task status is not running", () => {
|
||||
it("#then should return false without calling promptAsync", () => {
|
||||
//#given
|
||||
const client = createMockClient()
|
||||
const task = createRunningTask({ status: "completed" })
|
||||
|
||||
//#when
|
||||
const result = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
expect(client.session.promptAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task is running and nudge count is zero", () => {
|
||||
it("#when nudging for the first time #then should return true and call promptAsync", () => {
|
||||
//#given
|
||||
const client = createMockClient()
|
||||
const task = createRunningTask({ id: "task-nudge-first" })
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
//#when
|
||||
const result = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
expect(client.session.promptAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
// cleanup
|
||||
resetCouncilNudgeCount(task.id)
|
||||
})
|
||||
|
||||
it("#when nudging #then should update task.progress.lastUpdate", () => {
|
||||
//#given
|
||||
const client = createMockClient()
|
||||
const oldDate = new Date(Date.now() - 10000)
|
||||
const task = createRunningTask({
|
||||
id: "task-nudge-progress",
|
||||
progress: { lastUpdate: oldDate, toolCalls: 0 },
|
||||
})
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
//#when
|
||||
sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
|
||||
//#then
|
||||
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldDate.getTime())
|
||||
|
||||
// cleanup
|
||||
resetCouncilNudgeCount(task.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given nudge count increments on each call", () => {
|
||||
it("#when nudging multiple times below max #then should return true each time", () => {
|
||||
//#given
|
||||
const client = createMockClient()
|
||||
const task = createRunningTask({ id: "task-nudge-multi" })
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
//#when
|
||||
const result1 = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
const result2 = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
const result3 = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
|
||||
//#then
|
||||
expect(result1).toBe(true)
|
||||
expect(result2).toBe(true)
|
||||
expect(result3).toBe(true)
|
||||
expect(client.session.promptAsync).toHaveBeenCalledTimes(3)
|
||||
|
||||
// cleanup
|
||||
resetCouncilNudgeCount(task.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given nudge count reaches MAX_NUDGE_ATTEMPTS (5)", () => {
|
||||
it("#when called after max attempts #then should return false and clean up count", () => {
|
||||
//#given
|
||||
const client = createMockClient()
|
||||
const task = createRunningTask({ id: "task-nudge-max" })
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
//#when - exhaust all 5 attempts
|
||||
sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
const resultAfterMax = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
|
||||
//#then
|
||||
expect(resultAfterMax).toBe(false)
|
||||
expect(client.session.promptAsync).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given promptAsync throws an error", () => {
|
||||
it("#then should handle error gracefully and still return true", () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: { promptAsync: mock(() => Promise.reject(new Error("network error"))) },
|
||||
}
|
||||
const task = createRunningTask({ id: "task-nudge-error" })
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
//#when
|
||||
const result = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
|
||||
// cleanup
|
||||
resetCouncilNudgeCount(task.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetCouncilNudgeCount", () => {
|
||||
describe("#given nudge count has been incremented", () => {
|
||||
it("#when reset is called #then nudging should start from zero again", () => {
|
||||
//#given
|
||||
const client = createMockClient()
|
||||
const task = createRunningTask({ id: "task-nudge-reset" })
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
// exhaust all attempts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
}
|
||||
const resultBeforeReset = sendCouncilContinuationNudge(client as never, task, task.sessionID!)
|
||||
expect(resultBeforeReset).toBe(false)
|
||||
|
||||
//#when
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
//#then - should be able to nudge again
|
||||
const clientAfterReset = createMockClient()
|
||||
const resultAfterReset = sendCouncilContinuationNudge(
|
||||
clientAfterReset as never,
|
||||
task,
|
||||
task.sessionID!,
|
||||
)
|
||||
expect(resultAfterReset).toBe(true)
|
||||
expect(clientAfterReset.session.promptAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
// cleanup
|
||||
resetCouncilNudgeCount(task.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import {
|
||||
log,
|
||||
getAgentToolRestrictions,
|
||||
createInternalAgentTextPart,
|
||||
} from "../../shared"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
const COUNCIL_RESPONSE_TAG = "</COUNCIL_MEMBER_RESPONSE>"
|
||||
|
||||
const CONTINUATION_PROMPT =
|
||||
"You have not yet produced your final <COUNCIL_MEMBER_RESPONSE>. Continue your analysis and wrap your findings in <COUNCIL_MEMBER_RESPONSE> tags. If you are waiting for background tasks, use background_wait to block until they complete, then produce your response."
|
||||
|
||||
const MAX_NUDGE_ATTEMPTS = 5
|
||||
|
||||
const nudgeCountByTask = new Map<string, number>()
|
||||
|
||||
export function isCouncilMemberAgent(agentName: string | undefined): boolean {
|
||||
return !!agentName?.startsWith(COUNCIL_MEMBER_KEY_PREFIX)
|
||||
}
|
||||
|
||||
export function resetCouncilNudgeCount(taskId: string): void {
|
||||
nudgeCountByTask.delete(taskId)
|
||||
}
|
||||
|
||||
export function hasCouncilResponseTag(sessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type?: string; text?: string }> }>): boolean {
|
||||
for (let i = sessionMessages.length - 1; i >= 0; i--) {
|
||||
const msg = sessionMessages[i]
|
||||
if (msg.info?.role !== "assistant") continue
|
||||
const parts = msg.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "text" && part.text?.includes(COUNCIL_RESPONSE_TAG)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function sendCouncilContinuationNudge(
|
||||
client: OpencodeClient,
|
||||
task: BackgroundTask,
|
||||
sessionID: string,
|
||||
): boolean {
|
||||
if (task.status !== "running") return false
|
||||
|
||||
const count = nudgeCountByTask.get(task.id) ?? 0
|
||||
if (count >= MAX_NUDGE_ATTEMPTS) {
|
||||
log("[council-continuation] Max nudge attempts reached, allowing completion:", {
|
||||
taskId: task.id,
|
||||
attempts: count,
|
||||
})
|
||||
nudgeCountByTask.delete(task.id)
|
||||
return false
|
||||
}
|
||||
|
||||
nudgeCountByTask.set(task.id, count + 1)
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
log("[council-continuation] Nudging council member to produce response:", {
|
||||
taskId: task.id,
|
||||
attempt: count + 1,
|
||||
maxAttempts: MAX_NUDGE_ATTEMPTS,
|
||||
})
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[council-continuation] Nudge prompt error:", {
|
||||
taskId: task.id,
|
||||
error: String(error),
|
||||
})
|
||||
})
|
||||
|
||||
if (task.progress) {
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
|
||||
import { sessionHasCouncilResponse } from "./council-response-checker"
|
||||
|
||||
function createMockClient(
|
||||
messages: Array<{ info?: { role?: string }; parts?: Array<{ type?: string; text?: string }> }>,
|
||||
) {
|
||||
return {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve(messages)),
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
describe("sessionHasCouncilResponse", () => {
|
||||
describe("#given assistant message with closing council tag", () => {
|
||||
it("#when tag is in text part #then should return true", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "Some response</COUNCIL_MEMBER_RESPONSE>" }],
|
||||
},
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-1")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given empty messages array", () => {
|
||||
it("#when no messages exist #then should return false", async () => {
|
||||
//#given
|
||||
const client = createMockClient([])
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-empty")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given only user messages", () => {
|
||||
it("#when no assistant messages exist #then should return false", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{
|
||||
info: { role: "user" },
|
||||
parts: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-user-only")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given assistant messages without the closing tag", () => {
|
||||
it("#when tag is absent #then should return false", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "Just a regular assistant response" }],
|
||||
},
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-no-tag")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user message containing the closing tag", () => {
|
||||
it("#when only user has the tag #then should return false", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{
|
||||
info: { role: "user" },
|
||||
parts: [{ type: "text", text: "Here is the tag: </COUNCIL_MEMBER_RESPONSE>" }],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "I see your message" }],
|
||||
},
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-user-tag")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given assistant message with tag buried in longer text", () => {
|
||||
it("#when tag appears mid-text #then should return true", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Here is my analysis of the situation.\n\nLong content here.\n\n</COUNCIL_MEMBER_RESPONSE>\n\nMore text after.",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-buried-tag")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given API call throws an error", () => {
|
||||
it("#when client rejects #then should return false", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
messages: mock(() => Promise.reject(new Error("API error"))),
|
||||
},
|
||||
} as any
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-error")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given messages with missing or undefined parts", () => {
|
||||
it("#when parts are undefined #then should handle gracefully and return false", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: undefined,
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
},
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await sessionHasCouncilResponse(client, "ses-no-parts")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
const COUNCIL_RESPONSE_TAG = "</COUNCIL_MEMBER_RESPONSE>"
|
||||
|
||||
export async function sessionHasCouncilResponse(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = normalizeSDKResponse(
|
||||
response,
|
||||
[] as Array<{ info?: { role?: string }; parts?: Array<{ type?: string; text?: string }> }>,
|
||||
{ preferResponseOnMissingData: true },
|
||||
)
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.info?.role !== "assistant") continue
|
||||
const parts = msg.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "text" && part.text?.includes(COUNCIL_RESPONSE_TAG)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
log("[council-response-checker] Error checking session for response tag:", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,8 @@ import {
|
||||
} from "./subagent-spawn-limits"
|
||||
|
||||
import { writeTaskOutput } from "./task-output-writer"
|
||||
import { isCouncilMemberAgent, sendCouncilContinuationNudge, resetCouncilNudgeCount } from "./council-continuation-enforcer"
|
||||
import { sessionHasCouncilResponse } from "./council-response-checker"
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
|
||||
@@ -984,6 +986,18 @@ export class BackgroundManager {
|
||||
return field === "text" || field === "reasoning"
|
||||
}
|
||||
|
||||
private async nudgeCouncilMemberIfNeeded(task: BackgroundTask, sessionID: string): Promise<boolean> {
|
||||
if (!isCouncilMemberAgent(task.agent)) return false
|
||||
|
||||
const hasResponse = await sessionHasCouncilResponse(this.client, sessionID)
|
||||
if (hasResponse) {
|
||||
resetCouncilNudgeCount(task.id)
|
||||
return false
|
||||
}
|
||||
|
||||
return sendCouncilContinuationNudge(this.client, task, sessionID)
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
const props = event.properties
|
||||
|
||||
@@ -1135,7 +1149,6 @@ export class BackgroundManager {
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
this.recentlyCompactedSessions.add(sessionID)
|
||||
if (task.progress) {
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
@@ -1151,6 +1164,7 @@ export class BackgroundManager {
|
||||
recentlyCompactedSessions: this.recentlyCompactedSessions,
|
||||
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
|
||||
checkSessionTodos: (id) => this.checkSessionTodos(id),
|
||||
nudgeCouncilMemberIfNeeded: (task, sid) => this.nudgeCouncilMemberIfNeeded(task, sid),
|
||||
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
|
||||
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
|
||||
})
|
||||
@@ -1240,7 +1254,6 @@ export class BackgroundManager {
|
||||
|
||||
this.rootDescendantCounts.delete(sessionID)
|
||||
SessionCategoryRegistry.remove(sessionID)
|
||||
this.recentlyCompactedSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
if (event.type === "session.status") {
|
||||
@@ -1705,6 +1718,7 @@ export class BackgroundManager {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
resetCouncilNudgeCount(task.id)
|
||||
|
||||
if (task.rootSessionID) {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
@@ -2052,6 +2066,7 @@ export class BackgroundManager {
|
||||
|
||||
try {
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
task.sessionState = sessionStatus?.type
|
||||
// Handle retry before checking running state
|
||||
if (sessionStatus?.type === "retry") {
|
||||
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import {
|
||||
log,
|
||||
getAgentToolRestrictions,
|
||||
createInternalAgentTextPart,
|
||||
} from "../../shared"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
const CONTINUATION_PROMPT =
|
||||
"Your session was compacted (context summarized). Continue your analysis from where you left off. Report your findings when done."
|
||||
|
||||
export function sendPostCompactionContinuation(
|
||||
client: OpencodeClient,
|
||||
task: BackgroundTask,
|
||||
sessionID: string,
|
||||
): void {
|
||||
if (task.status !== "running") return
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] Post-compaction continuation error:", {
|
||||
taskId: task.id,
|
||||
error: String(error),
|
||||
})
|
||||
})
|
||||
|
||||
if (task.progress) {
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
}
|
||||
@@ -336,5 +336,72 @@ describe("handleSessionIdleBackgroundEvent", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when nudgeCouncilMemberIfNeeded is provided and returns true #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
const nudgeCouncilMemberIfNeeded = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
nudgeCouncilMemberIfNeeded,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when nudgeCouncilMemberIfNeeded is provided and returns false #then should complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
const nudgeCouncilMemberIfNeeded = mock(() => Promise.resolve(false))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
nudgeCouncilMemberIfNeeded,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
|
||||
})
|
||||
|
||||
it("#when nudgeCouncilMemberIfNeeded is not provided #then should complete task normally", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,10 +11,9 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
properties: Record<string, unknown>
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
recentlyCompactedSessions?: Set<string>
|
||||
onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
nudgeCouncilMemberIfNeeded?: (task: BackgroundTask, sessionID: string) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
emitIdleEvent: (sessionID: string) => void
|
||||
}): void {
|
||||
@@ -22,10 +21,9 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
properties,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
recentlyCompactedSessions,
|
||||
onPostCompactionIdle,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
nudgeCouncilMemberIfNeeded,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent,
|
||||
} = args
|
||||
@@ -36,12 +34,6 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
if (recentlyCompactedSessions?.has(sessionID)) {
|
||||
recentlyCompactedSessions.delete(sessionID)
|
||||
log("[background-agent] Skipping post-compaction session.idle:", { taskId: task.id, sessionID })
|
||||
onPostCompactionIdle?.(task, sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
@@ -103,6 +95,11 @@ export function handleSessionIdleBackgroundEvent(args: {
|
||||
return
|
||||
}
|
||||
|
||||
if (nudgeCouncilMemberIfNeeded) {
|
||||
const nudged = await nudgeCouncilMemberIfNeeded(task, sessionID)
|
||||
if (nudged) return
|
||||
}
|
||||
|
||||
await tryCompleteTask(task, "session.idle event")
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user