fix(continuation): skip internal user turns
This commit is contained in:
@@ -1,10 +1,49 @@
|
|||||||
declare const require: (name: string) => any
|
declare const require: (name: string) => any
|
||||||
const { describe, expect, test } = require("bun:test")
|
const { describe, expect, test } = require("bun:test")
|
||||||
import { extractResumeConfig, resumeSession } from "./resume"
|
|
||||||
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
||||||
|
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
|
|
||||||
describe("session-recovery resume", () => {
|
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", () => {
|
test("extractResumeConfig carries tools from last user message", () => {
|
||||||
// given
|
// given
|
||||||
const userMessage: MessageData = {
|
const userMessage: MessageData = {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import type { MessageData, ResumeConfig } from "./types"
|
import {
|
||||||
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
|
createInternalAgentContinuationTextPart,
|
||||||
|
isRealUserMessage,
|
||||||
|
resolveInheritedPromptTools,
|
||||||
|
} from "../../shared"
|
||||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||||
|
import type { MessageData, ResumeConfig } from "./types"
|
||||||
|
|
||||||
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
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 {
|
export function findLastUserMessage(messages: MessageData[]): MessageData | undefined {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
if (messages[i].info?.role === "user") {
|
const message = messages[i]
|
||||||
return messages[i]
|
if (message !== undefined && isRealUserMessage(message)) {
|
||||||
|
return message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface MessageData {
|
|||||||
type: string
|
type: string
|
||||||
id?: string
|
id?: string
|
||||||
text?: string
|
text?: string
|
||||||
|
synthetic?: boolean
|
||||||
thinking?: string
|
thinking?: string
|
||||||
name?: string
|
name?: string
|
||||||
input?: Record<string, unknown>
|
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 { 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 { log } from "../../shared/logger"
|
||||||
|
|
||||||
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
||||||
import type { SessionStateStore } from "./session-state"
|
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: {
|
export function handleNonIdleEvent(args: {
|
||||||
eventType: string
|
eventType: string
|
||||||
properties: Record<string, unknown> | undefined
|
properties: Record<string, unknown> | undefined
|
||||||
@@ -18,6 +48,11 @@ export function handleNonIdleEvent(args: {
|
|||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (role === "user") {
|
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)
|
const state = sessionStateStore.getExistingState(sessionID)
|
||||||
if (state?.countdownStartedAt) {
|
if (state?.countdownStartedAt) {
|
||||||
const elapsed = Date.now() - state.countdownStartedAt
|
const elapsed = Date.now() - state.countdownStartedAt
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
||||||
import { hasUnansweredQuestion } from "./pending-question-detection"
|
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||||
|
|
||||||
describe("hasUnansweredQuestion", () => {
|
describe("hasUnansweredQuestion", () => {
|
||||||
@@ -51,6 +52,42 @@ describe("hasUnansweredQuestion", () => {
|
|||||||
expect(hasUnansweredQuestion(messages)).toBe(false)
|
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", () => {
|
test("given assistant message with non-question tool, returns false", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{ info: { role: "user" } },
|
{ info: { role: "user" } },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isSyntheticOrInternalUserMessage } from "../../shared/internal-initiator-marker"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
|
|
||||||
@@ -5,6 +6,8 @@ interface MessagePart {
|
|||||||
type?: string
|
type?: string
|
||||||
name?: string
|
name?: string
|
||||||
toolName?: string
|
toolName?: string
|
||||||
|
text?: string
|
||||||
|
synthetic?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -20,7 +23,12 @@ export function hasUnansweredQuestion(messages: Message[]): boolean {
|
|||||||
const msg = messages[i]
|
const msg = messages[i]
|
||||||
const role = msg.info?.role ?? msg.role
|
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) {
|
if (role === "assistant" && msg.parts) {
|
||||||
const hasQuestion = msg.parts.some(
|
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 type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { isSyntheticOrInternalUserMessage, normalizeSDKResponse } from "../../shared"
|
||||||
import { isCompactionMessage } from "../../shared/compaction-marker"
|
import { isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types"
|
import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types"
|
||||||
@@ -31,6 +31,9 @@ export async function resolveLatestMessageInfo(
|
|||||||
encounteredCompaction = true
|
encounteredCompaction = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (isSyntheticOrInternalUserMessage(message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||||
return {
|
return {
|
||||||
resolvedInfo: {
|
resolvedInfo: {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface MessageInfo {
|
|||||||
|
|
||||||
export interface MessageWithInfo {
|
export interface MessageWithInfo {
|
||||||
info?: MessageInfo
|
info?: MessageInfo
|
||||||
parts?: Array<{ type?: string }>
|
parts?: Array<{ type?: string; text?: string; synthetic?: boolean }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedMessageInfo {
|
export interface ResolvedMessageInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user