fix(plugin): normalize event session ids
Handle OpenCode session events that carry the session ID under properties.info.id or properties.info.sessionID so background tasks and continuation hooks do not miss idle/error/delete events. Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks.
This commit is contained in:
@@ -5023,6 +5023,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"
|
||||
@@ -5747,6 +5795,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 = {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveInheritedPromptTools,
|
||||
createInternalAgentTextPart,
|
||||
} 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"
|
||||
@@ -118,7 +119,7 @@ interface MessagePartInfo {
|
||||
|
||||
interface EventProperties {
|
||||
sessionID?: string
|
||||
info?: { id?: string }
|
||||
info?: { id?: string; sessionID?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -1260,8 +1261,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
|
||||
@@ -1279,9 +1281,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)
|
||||
@@ -1312,7 +1314,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)
|
||||
@@ -1320,7 +1322,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)
|
||||
}
|
||||
|
||||
@@ -1404,7 +1406,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
|
||||
|
||||
@@ -1419,7 +1421,7 @@ 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
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
||||
log("[background-agent] Failed to flush pending parent wake:", { sessionID, error })
|
||||
@@ -1440,7 +1442,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)
|
||||
@@ -1469,9 +1471,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)
|
||||
|
||||
@@ -1529,7 +1530,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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