fix(continuation): skip internal user turns

This commit is contained in:
YeonGyu-Kim
2026-05-15 23:16:23 +09:00
parent c580b8f2ce
commit be25109f6d
10 changed files with 275 additions and 8 deletions
+40 -1
View File
@@ -1,10 +1,49 @@
declare const require: (name: string) => any
const { describe, expect, test } = require("bun:test")
import { extractResumeConfig, resumeSession } from "./resume"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
import type { MessageData } from "./types"
describe("session-recovery resume", () => {
test("findLastUserMessage skips synthetic and internally marked user messages", () => {
// given
const realUserMessage: MessageData = {
info: {
role: "user",
agent: "Sisyphus",
model: { providerID: "openai", modelID: "gpt-5.3-codex" },
},
parts: [{ type: "text", text: "real user task" }],
}
const syntheticUserMessage: MessageData = {
info: {
role: "user",
agent: "Atlas",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
},
parts: [{ type: "text", text: "synthetic wake", synthetic: true }],
}
const internalUserMessage: MessageData = {
info: {
role: "user",
agent: "Hephaestus",
model: { providerID: "openai", modelID: "gpt-5.4" },
},
parts: [{ type: "text", text: `internal wake\n${OMO_INTERNAL_INITIATOR_MARKER}` }],
}
// when
const result = findLastUserMessage([
realUserMessage,
syntheticUserMessage,
internalUserMessage,
])
// then
expect(result).toBe(realUserMessage)
})
test("extractResumeConfig carries tools from last user message", () => {
// given
const userMessage: MessageData = {
+9 -4
View File
@@ -1,7 +1,11 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData, ResumeConfig } from "./types"
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
import {
createInternalAgentContinuationTextPart,
isRealUserMessage,
resolveInheritedPromptTools,
} from "../../shared"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
import type { MessageData, ResumeConfig } from "./types"
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
@@ -9,8 +13,9 @@ type Client = ReturnType<typeof createOpencodeClient>
export function findLastUserMessage(messages: MessageData[]): MessageData | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info?.role === "user") {
return messages[i]
const message = messages[i]
if (message !== undefined && isRealUserMessage(message)) {
return message
}
}
return undefined
+1
View File
@@ -82,6 +82,7 @@ export interface MessageData {
type: string
id?: string
text?: string
synthetic?: boolean
thinking?: string
name?: string
input?: Record<string, unknown>
@@ -0,0 +1,70 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { handleNonIdleEvent } from "./non-idle-events"
import { createSessionStateStore, type SessionStateStore } from "./session-state"
describe("handleNonIdleEvent", () => {
let sessionStateStore: SessionStateStore
beforeEach(() => {
sessionStateStore = createSessionStateStore()
})
afterEach(() => {
sessionStateStore.shutdown()
})
test("given synthetic user message update, keeps continuation countdown state intact", () => {
// given
const sessionID = "ses_synthetic_user_event"
const state = sessionStateStore.getState(sessionID)
state.countdownStartedAt = Date.now() - 10_000
state.wasCancelled = true
state.tokenLimitDetected = true
// when
handleNonIdleEvent({
eventType: "message.updated",
properties: {
sessionID,
info: { role: "user" },
parts: [{ type: "text", text: "internal wake", synthetic: true }],
},
sessionStateStore,
})
// then
expect(state.countdownStartedAt).toBeDefined()
expect(state.wasCancelled).toBe(true)
expect(state.tokenLimitDetected).toBe(true)
})
test("given internally marked user message update, keeps continuation countdown state intact", () => {
// given
const sessionID = "ses_internal_user_event"
const state = sessionStateStore.getState(sessionID)
state.countdownStartedAt = Date.now() - 10_000
state.wasCancelled = true
state.tokenLimitDetected = true
// when
handleNonIdleEvent({
eventType: "message.updated",
properties: {
sessionID,
info: { role: "user" },
parts: [
{ type: "text", text: `internal wake\n${OMO_INTERNAL_INITIATOR_MARKER}` },
],
},
sessionStateStore,
})
// then
expect(state.countdownStartedAt).toBeDefined()
expect(state.wasCancelled).toBe(true)
expect(state.tokenLimitDetected).toBe(true)
})
})
@@ -1,9 +1,39 @@
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import type { InternalInitiatorTextPartLike } from "../../shared/internal-initiator-marker"
import { isSyntheticOrInternalOnlyTextParts } from "../../shared/internal-initiator-marker"
import { log } from "../../shared/logger"
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
import type { SessionStateStore } from "./session-state"
function isEventPart(value: unknown): value is InternalInitiatorTextPartLike {
if (typeof value !== "object" || value === null) {
return false
}
const record = value as Record<string, unknown>
const type = record.type
const text = record.text
const synthetic = record.synthetic
return (
(type === undefined || typeof type === "string") &&
(text === undefined || typeof text === "string") &&
(synthetic === undefined || typeof synthetic === "boolean")
)
}
function resolveEventParts(
properties: Record<string, unknown> | undefined
): InternalInitiatorTextPartLike[] | undefined {
const parts = properties?.parts
if (!Array.isArray(parts) || !parts.every(isEventPart)) {
return undefined
}
return parts
}
export function handleNonIdleEvent(args: {
eventType: string
properties: Record<string, unknown> | undefined
@@ -18,6 +48,11 @@ export function handleNonIdleEvent(args: {
if (!sessionID) return
if (role === "user") {
const parts = resolveEventParts(properties)
if (isSyntheticOrInternalOnlyTextParts(parts)) {
log(`[${HOOK_NAME}] Ignoring synthetic/internal user message event`, { sessionID })
return
}
const state = sessionStateStore.getExistingState(sessionID)
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt
@@ -1,6 +1,7 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { hasUnansweredQuestion } from "./pending-question-detection"
describe("hasUnansweredQuestion", () => {
@@ -51,6 +52,42 @@ describe("hasUnansweredQuestion", () => {
expect(hasUnansweredQuestion(messages)).toBe(false)
})
test("given synthetic user message after question, still treats question as unanswered", () => {
const messages = [
{
info: { role: "assistant" },
parts: [
{ type: "tool_use", name: "question" },
],
},
{
info: { role: "user" },
parts: [
{ type: "text", text: "internal continuation", synthetic: true },
],
},
]
expect(hasUnansweredQuestion(messages)).toBe(true)
})
test("given internally marked user message after question, still treats question as unanswered", () => {
const messages = [
{
info: { role: "assistant" },
parts: [
{ type: "tool_use", name: "question" },
],
},
{
info: { role: "user" },
parts: [
{ type: "text", text: `internal continuation\n${OMO_INTERNAL_INITIATOR_MARKER}` },
],
},
]
expect(hasUnansweredQuestion(messages)).toBe(true)
})
test("given assistant message with non-question tool, returns false", () => {
const messages = [
{ info: { role: "user" } },
@@ -1,3 +1,4 @@
import { isSyntheticOrInternalUserMessage } from "../../shared/internal-initiator-marker"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./constants"
@@ -5,6 +6,8 @@ interface MessagePart {
type?: string
name?: string
toolName?: string
text?: string
synthetic?: boolean
}
interface Message {
@@ -20,7 +23,12 @@ export function hasUnansweredQuestion(messages: Message[]): boolean {
const msg = messages[i]
const role = msg.info?.role ?? msg.role
if (role === "user") return false
if (role === "user") {
if (isSyntheticOrInternalUserMessage(msg)) {
continue
}
return false
}
if (role === "assistant" && msg.parts) {
const hasQuestion = msg.parts.some(
@@ -0,0 +1,69 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { resolveLatestMessageInfo } from "./resolve-message-info"
import type { MessageWithInfo } from "./types"
describe("resolveLatestMessageInfo", () => {
test("given synthetic latest user info, skips it and resolves the prior real user info", async () => {
// given
const realModel = { providerID: "openai", modelID: "gpt-5.3-codex" }
const syntheticModel = { providerID: "anthropic", modelID: "claude-sonnet-4-6" }
const messages: MessageWithInfo[] = [
{
info: { role: "user", agent: "sisyphus", model: realModel },
parts: [{ type: "text", text: "real user task" }],
},
{
info: { role: "user", agent: "atlas", model: syntheticModel },
parts: [{ type: "text", text: "synthetic wake", synthetic: true }],
},
]
// when
const result = await resolveLatestMessageInfo(
unsafeTestValue({}),
"ses_synthetic_latest_info",
messages,
)
// then
expect(result.resolvedInfo).toEqual({
agent: "sisyphus",
model: realModel,
tools: undefined,
})
})
test("given internally marked latest user info, skips it and resolves the prior real user info", async () => {
// given
const realModel = { providerID: "openai", modelID: "gpt-5.3-codex" }
const internalModel = { providerID: "openai", modelID: "gpt-5.4" }
const messages: MessageWithInfo[] = [
{
info: { role: "user", agent: "sisyphus", model: realModel },
parts: [{ type: "text", text: "real user task" }],
},
{
info: { role: "user", agent: "hephaestus", model: internalModel },
parts: [{ type: "text", text: `internal wake\n${OMO_INTERNAL_INITIATOR_MARKER}` }],
},
]
// when
const result = await resolveLatestMessageInfo(
unsafeTestValue({}),
"ses_internal_latest_info",
messages,
)
// then
expect(result.resolvedInfo).toEqual({
agent: "sisyphus",
model: realModel,
tools: undefined,
})
})
})
@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared"
import { isSyntheticOrInternalUserMessage, normalizeSDKResponse } from "../../shared"
import { isCompactionMessage } from "../../shared/compaction-marker"
import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types"
@@ -31,6 +31,9 @@ export async function resolveLatestMessageInfo(
encounteredCompaction = true
continue
}
if (isSyntheticOrInternalUserMessage(message)) {
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
return {
resolvedInfo: {
@@ -54,7 +54,7 @@ export interface MessageInfo {
export interface MessageWithInfo {
info?: MessageInfo
parts?: Array<{ type?: string }>
parts?: Array<{ type?: string; text?: string; synthetic?: boolean }>
}
export interface ResolvedMessageInfo {