fix: resolve 25 pre-publish blockers
- postinstall.mjs: fix alias package detection - migrate-legacy-plugin-entry: dedupe + regression tests - task_system: default consistency across runtime paths - task() contract: consistent tool behavior - runtime model selection, tool cap, stale-task cancellation - recovery sanitization, context-limit gating - Ralph semantic DONE hardening, Atlas fallback persistence - native-skill description/content, skill path traversal guard - publish workflow: platform awaited via reusable workflow job - release: version edits reapplied before commit/tag - JSONC plugin migration: top-level plugin key safety - cold-cache: user fallback models skip disconnected providers - docs/version/release framing updates Verified: bun test (4599 pass), tsc --noEmit clean, bun run build clean
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
const replaceEmptyTextPartsAsync = mock(() => Promise.resolve(false))
|
||||
const injectTextPartAsync = mock(() => Promise.resolve(false))
|
||||
const findMessagesWithEmptyTextPartsFromSDK = mock(() => Promise.resolve([] as string[]))
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
normalizeSDKResponse: (response: { data?: unknown[] }) => response.data ?? [],
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
isSqliteBackend: () => true,
|
||||
}))
|
||||
|
||||
mock.module("../session-recovery/storage", () => ({
|
||||
findEmptyMessages: () => [],
|
||||
findMessagesWithEmptyTextParts: () => [],
|
||||
injectTextPart: () => false,
|
||||
replaceEmptyTextParts: () => false,
|
||||
}))
|
||||
|
||||
mock.module("../session-recovery/storage/empty-text", () => ({
|
||||
replaceEmptyTextPartsAsync,
|
||||
findMessagesWithEmptyTextPartsFromSDK,
|
||||
}))
|
||||
|
||||
mock.module("../session-recovery/storage/text-part-injector", () => ({
|
||||
injectTextPartAsync,
|
||||
}))
|
||||
|
||||
async function importFreshMessageBuilder(): Promise<typeof import("./message-builder")> {
|
||||
return import(`./message-builder?test=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("sanitizeEmptyMessagesBeforeSummarize", () => {
|
||||
beforeEach(() => {
|
||||
replaceEmptyTextPartsAsync.mockReset()
|
||||
replaceEmptyTextPartsAsync.mockResolvedValue(false)
|
||||
injectTextPartAsync.mockReset()
|
||||
injectTextPartAsync.mockResolvedValue(false)
|
||||
findMessagesWithEmptyTextPartsFromSDK.mockReset()
|
||||
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue([])
|
||||
})
|
||||
|
||||
test("#given sqlite message with tool content and empty text part #when sanitizing #then it fixes the mixed-content message", async () => {
|
||||
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
|
||||
const client = {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg-1" },
|
||||
parts: [
|
||||
{ type: "tool_result", text: "done" },
|
||||
{ type: "text", text: "" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
} as never
|
||||
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue(["msg-1"])
|
||||
replaceEmptyTextPartsAsync.mockResolvedValue(true)
|
||||
|
||||
const fixedCount = await sanitizeEmptyMessagesBeforeSummarize("ses-1", client)
|
||||
|
||||
expect(fixedCount).toBe(1)
|
||||
expect(replaceEmptyTextPartsAsync).toHaveBeenCalledWith(client, "ses-1", "msg-1", PLACEHOLDER_TEXT)
|
||||
expect(injectTextPartAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("#given sqlite message with mixed content and failed replacement #when sanitizing #then it injects the placeholder text part", async () => {
|
||||
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
|
||||
const client = {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg-2" },
|
||||
parts: [
|
||||
{ type: "tool_use", text: "call" },
|
||||
{ type: "text", text: "" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
} as never
|
||||
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue(["msg-2"])
|
||||
injectTextPartAsync.mockResolvedValue(true)
|
||||
|
||||
const fixedCount = await sanitizeEmptyMessagesBeforeSummarize("ses-2", client)
|
||||
|
||||
expect(fixedCount).toBe(1)
|
||||
expect(injectTextPartAsync).toHaveBeenCalledWith(client, "ses-2", "msg-2", PLACEHOLDER_TEXT)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
injectTextPart,
|
||||
replaceEmptyTextParts,
|
||||
} from "../session-recovery/storage"
|
||||
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
|
||||
import { findMessagesWithEmptyTextPartsFromSDK, replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
|
||||
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
|
||||
import type { Client } from "./client"
|
||||
|
||||
@@ -86,12 +86,14 @@ export async function sanitizeEmptyMessagesBeforeSummarize(
|
||||
): Promise<number> {
|
||||
if (client && isSqliteBackend()) {
|
||||
const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID)
|
||||
if (emptyMessageIds.length === 0) {
|
||||
const emptyTextPartIds = await findMessagesWithEmptyTextPartsFromSDK(client, sessionID)
|
||||
const allIds = [...new Set([...emptyMessageIds, ...emptyTextPartIds])]
|
||||
if (allIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let fixedCount = 0
|
||||
for (const messageID of emptyMessageIds) {
|
||||
for (const messageID of allIds) {
|
||||
const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
|
||||
if (replaced) {
|
||||
fixedCount++
|
||||
@@ -107,7 +109,7 @@ export async function sanitizeEmptyMessagesBeforeSummarize(
|
||||
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
|
||||
sessionID,
|
||||
fixedCount,
|
||||
totalEmpty: emptyMessageIds.length,
|
||||
totalEmpty: allIds.length,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { isAgentRegistered } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
|
||||
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
|
||||
@@ -48,24 +49,33 @@ export async function injectBoulderContinuation(input: {
|
||||
const preferredSessionContext = preferredTaskSessionId
|
||||
? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]`
|
||||
: ""
|
||||
const prompt =
|
||||
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
|
||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||
preferredSessionContext +
|
||||
worktreeContext
|
||||
const prompt =
|
||||
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
|
||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||
preferredSessionContext +
|
||||
worktreeContext
|
||||
const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
||||
sessionID,
|
||||
agent: continuationAgent ?? agent ?? "unknown",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
|
||||
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
|
||||
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: getAgentDisplayName(agent ?? "atlas"),
|
||||
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: getAgentConfigKey(continuationAgent),
|
||||
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-compaction-storage-${randomUUID()}`)
|
||||
@@ -66,6 +66,8 @@ describe("atlas hook compaction agent filtering", () => {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
clearBoulderState(testDirectory)
|
||||
_resetForTesting()
|
||||
registerAgentName("atlas")
|
||||
registerAgentName("sisyphus")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
@@ -64,6 +64,8 @@ describe("atlas hook idle-event session lineage", () => {
|
||||
promptCalls = []
|
||||
clearBoulderState(testDirectory)
|
||||
_resetForTesting()
|
||||
registerAgentName("atlas")
|
||||
registerAgentName("sisyphus")
|
||||
subagentSessions.clear()
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { getSessionAgent, isAgentRegistered, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
@@ -141,7 +141,15 @@ export async function handleAtlasSessionIdle(input: {
|
||||
if (subagentSessions.has(sessionID)) {
|
||||
const sessionAgent = getSessionAgent(sessionID)
|
||||
const agentKey = getAgentConfigKey(sessionAgent ?? "")
|
||||
const requiredAgentKey = getAgentConfigKey(boulderState.agent ?? "atlas")
|
||||
const requiredAgentName = boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
|
||||
if (!requiredAgentName || !isAgentRegistered(requiredAgentName)) {
|
||||
log(`[${HOOK_NAME}] Skipped: boulder agent is unavailable for continuation`, {
|
||||
sessionID,
|
||||
requiredAgent: boulderState.agent ?? "unknown",
|
||||
})
|
||||
return
|
||||
}
|
||||
const requiredAgentKey = getAgentConfigKey(requiredAgentName)
|
||||
const agentMatches =
|
||||
agentKey === requiredAgentKey ||
|
||||
(requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus"))
|
||||
@@ -149,10 +157,10 @@ export async function handleAtlasSessionIdle(input: {
|
||||
log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, {
|
||||
sessionID,
|
||||
agent: sessionAgent ?? "unknown",
|
||||
requiredAgent: boulderState.agent ?? "atlas",
|
||||
})
|
||||
return
|
||||
}
|
||||
requiredAgent: requiredAgentName,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
readBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import type { PendingTaskRef } from "./types"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
|
||||
@@ -90,6 +90,9 @@ describe("atlas hook", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
registerAgentName("atlas")
|
||||
registerAgentName("sisyphus")
|
||||
TEST_DIR = join(tmpdir(), `atlas-test-${randomUUID()}`)
|
||||
SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
|
||||
if (!existsSync(TEST_DIR)) {
|
||||
@@ -102,6 +105,7 @@ describe("atlas hook", () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting()
|
||||
clearBoulderState(TEST_DIR)
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
@@ -1182,9 +1186,11 @@ session_id: ses_untrusted_999
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
subagentSessions.clear()
|
||||
setupMessageStorage(MAIN_SESSION_ID, "atlas")
|
||||
})
|
||||
registerAgentName("atlas")
|
||||
registerAgentName("sisyphus")
|
||||
subagentSessions.clear()
|
||||
setupMessageStorage(MAIN_SESSION_ID, "atlas")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMessageStorage(MAIN_SESSION_ID)
|
||||
|
||||
@@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { autoMigrateLegacyPluginEntry } from "./auto-migrate"
|
||||
async function importFreshAutoMigrateModule(): Promise<typeof import("./auto-migrate")> {
|
||||
return import(`./auto-migrate?test=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
describe("autoMigrateLegacyPluginEntry", () => {
|
||||
let testConfigDir = ""
|
||||
@@ -17,13 +19,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
|
||||
})
|
||||
|
||||
describe("#given opencode.json has a bare legacy plugin entry", () => {
|
||||
it("#then replaces oh-my-opencode with oh-my-openagent", () => {
|
||||
it("#then replaces oh-my-opencode with oh-my-openagent", async () => {
|
||||
// given
|
||||
writeFileSync(
|
||||
join(testConfigDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n",
|
||||
)
|
||||
|
||||
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
|
||||
|
||||
// when
|
||||
const result = autoMigrateLegacyPluginEntry(testConfigDir)
|
||||
|
||||
@@ -37,13 +41,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
|
||||
})
|
||||
|
||||
describe("#given opencode.json has a version-pinned legacy entry", () => {
|
||||
it("#then preserves the version suffix", () => {
|
||||
it("#then preserves the version suffix", async () => {
|
||||
// given
|
||||
writeFileSync(
|
||||
join(testConfigDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n",
|
||||
)
|
||||
|
||||
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
|
||||
|
||||
// when
|
||||
const result = autoMigrateLegacyPluginEntry(testConfigDir)
|
||||
|
||||
@@ -57,13 +63,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
|
||||
})
|
||||
|
||||
describe("#given both canonical and legacy entries exist", () => {
|
||||
it("#then removes legacy entry and keeps canonical", () => {
|
||||
it("#then removes legacy entry and keeps canonical", async () => {
|
||||
// given
|
||||
writeFileSync(
|
||||
join(testConfigDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2) + "\n",
|
||||
)
|
||||
|
||||
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
|
||||
|
||||
// when
|
||||
const result = autoMigrateLegacyPluginEntry(testConfigDir)
|
||||
|
||||
@@ -75,8 +83,9 @@ describe("autoMigrateLegacyPluginEntry", () => {
|
||||
})
|
||||
|
||||
describe("#given no config file exists", () => {
|
||||
it("#then returns migrated false", () => {
|
||||
it("#then returns migrated false", async () => {
|
||||
// given - empty dir
|
||||
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
|
||||
|
||||
// when
|
||||
const result = autoMigrateLegacyPluginEntry(testConfigDir)
|
||||
@@ -88,13 +97,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
|
||||
})
|
||||
|
||||
describe("#given opencode.jsonc has comments and a legacy entry", () => {
|
||||
it("#then preserves comments and replaces entry", () => {
|
||||
it("#then preserves comments and replaces entry", async () => {
|
||||
// given
|
||||
writeFileSync(
|
||||
join(testConfigDir, "opencode.jsonc"),
|
||||
'{\n // my config\n "plugin": ["oh-my-opencode"]\n}\n',
|
||||
)
|
||||
|
||||
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
|
||||
|
||||
// when
|
||||
const result = autoMigrateLegacyPluginEntry(testConfigDir)
|
||||
|
||||
@@ -108,11 +119,13 @@ describe("autoMigrateLegacyPluginEntry", () => {
|
||||
})
|
||||
|
||||
describe("#given only canonical entry exists", () => {
|
||||
it("#then returns migrated false and leaves file untouched", () => {
|
||||
it("#then returns migrated false and leaves file untouched", async () => {
|
||||
// given
|
||||
const original = JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n"
|
||||
writeFileSync(join(testConfigDir, "opencode.json"), original)
|
||||
|
||||
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
|
||||
|
||||
// when
|
||||
const result = autoMigrateLegacyPluginEntry(testConfigDir)
|
||||
|
||||
|
||||
@@ -669,4 +669,43 @@ describe("preemptive-compaction", () => {
|
||||
|
||||
expect(ctx.client.session.summarize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should ignore stale cached Anthropic limits for older models", async () => {
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
|
||||
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_old_anthropic_limit"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 170000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
{ title: "", output: "test", metadata: null }
|
||||
)
|
||||
|
||||
expect(ctx.client.session.summarize).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,7 +186,7 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
})
|
||||
|
||||
describe("#given semantic completion patterns", () => {
|
||||
test("#when agent says 'task is complete' #then should detect semantic completion", async () => {
|
||||
test("#when agent says 'task is complete' without explicit promise #then should NOT detect completion", async () => {
|
||||
// #given
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
@@ -205,10 +205,10 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(true)
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
|
||||
test("#when agent says 'all items are done' #then should detect semantic completion", async () => {
|
||||
test("#when agent says 'all items are done' without explicit promise #then should NOT detect completion", async () => {
|
||||
// #given
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
@@ -227,10 +227,10 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(true)
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
|
||||
test("#when agent says 'nothing left to do' #then should detect semantic completion", async () => {
|
||||
test("#when agent says 'nothing left to do' without explicit promise #then should NOT detect completion", async () => {
|
||||
// #given
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
@@ -249,10 +249,10 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(true)
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
|
||||
test("#when agent says 'successfully completed all' #then should detect semantic completion", async () => {
|
||||
test("#when agent says 'successfully completed all' without explicit promise #then should NOT detect completion", async () => {
|
||||
// #given
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
@@ -271,7 +271,7 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(true)
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
|
||||
test("#when promise is VERIFIED #then semantic completion should NOT trigger", async () => {
|
||||
@@ -295,6 +295,75 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
// #then
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
|
||||
test("#when completion text appears inside a quote #then should NOT detect completion", async () => {
|
||||
// #given
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: 'The user wrote: "the task is complete". I am still working.' }],
|
||||
},
|
||||
]
|
||||
const ctx = createPluginInput(messages)
|
||||
|
||||
// #when
|
||||
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: "session-quoted",
|
||||
promise: "DONE",
|
||||
apiTimeoutMs: 1000,
|
||||
directory: "/tmp",
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
|
||||
test("#when tool_result says all items are complete #then should NOT detect completion", async () => {
|
||||
// #given
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_result", text: "Background agent report: all items are complete." },
|
||||
{ type: "text", text: "Still validating the final behavior." },
|
||||
],
|
||||
},
|
||||
]
|
||||
const ctx = createPluginInput(messages)
|
||||
|
||||
// #when
|
||||
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: "session-tool-result-semantic",
|
||||
promise: "DONE",
|
||||
apiTimeoutMs: 1000,
|
||||
directory: "/tmp",
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
|
||||
test("#when assistant says complete but not actually done #then should NOT detect completion", async () => {
|
||||
// #given
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "The implementation looks complete, but I still need to run the tests." }],
|
||||
},
|
||||
]
|
||||
const ctx = createPluginInput(messages)
|
||||
|
||||
// #when
|
||||
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: "session-not-actually-done",
|
||||
promise: "DONE",
|
||||
apiTimeoutMs: 1000,
|
||||
directory: "/tmp",
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ const SEMANTIC_COMPLETION_PATTERNS = [
|
||||
/\bnothing\s+(?:left|more|remaining)\s+to\s+(?:do|implement|fix)\b/i,
|
||||
]
|
||||
|
||||
const SEMANTIC_DONE_FALLBACK_ENABLED = false
|
||||
|
||||
export function detectSemanticCompletion(text: string): boolean {
|
||||
return SEMANTIC_COMPLETION_PATTERNS.some((pattern) => pattern.test(text))
|
||||
}
|
||||
@@ -65,9 +67,8 @@ export function detectCompletionInTranscript(
|
||||
const entryText = extractTranscriptEntryText(entry)
|
||||
if (!entryText) continue
|
||||
if (pattern.test(entryText)) return true
|
||||
// Fallback: semantic completion only for DONE promise and assistant entries
|
||||
const isAssistantEntry = entry.type === "assistant" || entry.type === "text"
|
||||
if (promise === "DONE" && isAssistantEntry && detectSemanticCompletion(entryText)) {
|
||||
if (SEMANTIC_DONE_FALLBACK_ENABLED && promise === "DONE" && isAssistantEntry && detectSemanticCompletion(entryText)) {
|
||||
log("[ralph-loop] WARNING: Semantic completion detected in transcript (agent used natural language instead of <promise>DONE</promise>)")
|
||||
return true
|
||||
}
|
||||
@@ -135,8 +136,7 @@ export async function detectCompletionInSessionMessages(
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: semantic completion only for DONE promise
|
||||
if (options.promise === "DONE" && detectSemanticCompletion(responseText)) {
|
||||
if (SEMANTIC_DONE_FALLBACK_ENABLED && options.promise === "DONE" && detectSemanticCompletion(responseText)) {
|
||||
log("[ralph-loop] WARNING: Semantic completion detected (agent used natural language instead of <promise>DONE</promise>)", {
|
||||
sessionID: options.sessionID,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { readMessagesFromSDK, readPartsFromSDK } from "../storage"
|
||||
import { readMessages } from "./messages-reader"
|
||||
import { readParts } from "./parts-reader"
|
||||
async function importFreshReaders() {
|
||||
const token = `${Date.now()}-${Math.random()}`
|
||||
const [{ readMessagesFromSDK, readMessages }, { readPartsFromSDK, readParts }] = await Promise.all([
|
||||
import(`./messages-reader?test=${token}`),
|
||||
import(`./parts-reader?test=${token}`),
|
||||
])
|
||||
|
||||
return { readMessagesFromSDK, readPartsFromSDK, readMessages, readParts }
|
||||
}
|
||||
|
||||
function createMockClient(handlers: {
|
||||
messages?: (sessionID: string) => unknown[]
|
||||
@@ -28,6 +34,7 @@ function createMockClient(handlers: {
|
||||
describe("session-recovery storage SDK readers", () => {
|
||||
it("readPartsFromSDK returns empty array when fetch fails", async () => {
|
||||
//#given a client that throws on request
|
||||
const { readPartsFromSDK } = await importFreshReaders()
|
||||
const client = createMockClient({}) as Parameters<typeof readPartsFromSDK>[0]
|
||||
|
||||
//#when readPartsFromSDK is called
|
||||
@@ -39,6 +46,7 @@ describe("session-recovery storage SDK readers", () => {
|
||||
|
||||
it("readPartsFromSDK returns stored parts from SDK response", async () => {
|
||||
//#given a client that returns a message with parts
|
||||
const { readPartsFromSDK } = await importFreshReaders()
|
||||
const sessionID = "ses_test"
|
||||
const messageID = "msg_test"
|
||||
const storedParts = [
|
||||
@@ -58,6 +66,7 @@ describe("session-recovery storage SDK readers", () => {
|
||||
|
||||
it("readMessagesFromSDK normalizes and sorts messages", async () => {
|
||||
//#given a client that returns messages list
|
||||
const { readMessagesFromSDK } = await importFreshReaders()
|
||||
const sessionID = "ses_test"
|
||||
const client = createMockClient({
|
||||
messages: () => [
|
||||
@@ -78,8 +87,9 @@ describe("session-recovery storage SDK readers", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("readParts returns empty array for nonexistent message", () => {
|
||||
it("readParts returns empty array for nonexistent message", async () => {
|
||||
//#given a message ID that has no stored parts
|
||||
const { readParts } = await importFreshReaders()
|
||||
//#when readParts is called
|
||||
const parts = readParts("msg_nonexistent")
|
||||
|
||||
@@ -87,8 +97,9 @@ describe("session-recovery storage SDK readers", () => {
|
||||
expect(parts).toEqual([])
|
||||
})
|
||||
|
||||
it("readMessages returns empty array for nonexistent session", () => {
|
||||
it("readMessages returns empty array for nonexistent session", async () => {
|
||||
//#given a session ID that has no stored messages
|
||||
const { readMessages } = await importFreshReaders()
|
||||
//#when readMessages is called
|
||||
const messages = readMessages("ses_nonexistent")
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
import * as worktreeDetector from "./worktree-detector"
|
||||
import * as worktreeDetector from "./worktree-detector"
|
||||
|
||||
describe("start-work hook", () => {
|
||||
let testDir: string
|
||||
@@ -26,6 +25,9 @@ describe("start-work hook", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionState._resetForTesting()
|
||||
sessionState.registerAgentName("atlas")
|
||||
sessionState.registerAgentName("sisyphus")
|
||||
testDir = join(tmpdir(), `start-work-test-${randomUUID()}`)
|
||||
sisyphusDir = join(testDir, ".sisyphus")
|
||||
if (!existsSync(testDir)) {
|
||||
@@ -38,6 +40,7 @@ describe("start-work hook", () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionState._resetForTesting()
|
||||
clearBoulderState(testDir)
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
@@ -409,7 +412,7 @@ describe("start-work hook", () => {
|
||||
// given
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {},
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
||||
}
|
||||
|
||||
@@ -422,6 +425,29 @@ describe("start-work hook", () => {
|
||||
// then
|
||||
expect(output.message.agent).toBe("Atlas (Plan Executor)")
|
||||
})
|
||||
|
||||
test("should keep the current agent when Atlas is unavailable", async () => {
|
||||
// given
|
||||
sessionState._resetForTesting()
|
||||
sessionState.registerAgentName("sisyphus")
|
||||
sessionState.updateSessionAgent("ses-prometheus-to-sisyphus", "sisyphus")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "<session-context></session-context>" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "ses-prometheus-to-sisyphus" },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message.agent).toBe("Sisyphus (Ultraworker)")
|
||||
expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus")
|
||||
})
|
||||
})
|
||||
|
||||
describe("worktree support", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { updateSessionAgent, isAgentRegistered } from "../../features/claude-code-session-state"
|
||||
import { getSessionAgent, isAgentRegistered, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { detectWorktreePath } from "./worktree-detector"
|
||||
import { parseUserRequest } from "./parse-user-request"
|
||||
|
||||
@@ -80,14 +80,13 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
if (!promptText.includes("<session-context>")) return
|
||||
|
||||
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
|
||||
const atlasDisplayName = getAgentDisplayName("atlas")
|
||||
if (isAgentRegistered("atlas") || isAgentRegistered(atlasDisplayName)) {
|
||||
updateSessionAgent(input.sessionID, "atlas")
|
||||
if (output.message) {
|
||||
output.message["agent"] = atlasDisplayName
|
||||
}
|
||||
} else {
|
||||
log(`[${HOOK_NAME}] Atlas agent not available, continuing with current agent`, { sessionID: input.sessionID })
|
||||
const activeAgent = isAgentRegistered("atlas")
|
||||
? "atlas"
|
||||
: getSessionAgent(input.sessionID) ?? "sisyphus"
|
||||
const activeAgentDisplayName = getAgentDisplayName(activeAgent)
|
||||
updateSessionAgent(input.sessionID, activeAgent)
|
||||
if (output.message) {
|
||||
output.message["agent"] = activeAgentDisplayName
|
||||
}
|
||||
|
||||
const existingState = readBoulderState(ctx.directory)
|
||||
@@ -116,7 +115,7 @@ The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
||||
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
||||
} else {
|
||||
if (existingState) clearBoulderState(ctx.directory)
|
||||
const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath)
|
||||
const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo = `
|
||||
@@ -223,7 +222,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
|
||||
} else if (incompletePlans.length === 1) {
|
||||
const planPath = incompletePlans[0]
|
||||
const progress = getPlanProgress(planPath)
|
||||
const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath)
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo += `
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface TasksTodowriteDisablerConfig {
|
||||
export function createTasksTodowriteDisablerHook(
|
||||
config: TasksTodowriteDisablerConfig,
|
||||
) {
|
||||
const isTaskSystemEnabled = config.experimental?.task_system ?? false;
|
||||
const isTaskSystemEnabled = config.experimental?.task_system ?? true;
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("tasks-todowrite-disabler", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("when experimental.task_system is disabled or undefined", () => {
|
||||
describe("when experimental.task_system is disabled", () => {
|
||||
test("should not block TodoWrite when flag is false", async () => {
|
||||
// given
|
||||
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: false } })
|
||||
@@ -78,7 +78,7 @@ describe("tasks-todowrite-disabler", () => {
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should not block TodoWrite when experimental is undefined", async () => {
|
||||
test("should block TodoWrite when experimental is undefined because task_system defaults to enabled", async () => {
|
||||
// given
|
||||
const hook = createTasksTodowriteDisablerHook({})
|
||||
const input = {
|
||||
@@ -93,7 +93,7 @@ describe("tasks-todowrite-disabler", () => {
|
||||
// when / then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
).rejects.toThrow("TodoRead/TodoWrite are DISABLED")
|
||||
})
|
||||
|
||||
test("should not block TodoRead when flag is false", async () => {
|
||||
|
||||
Reference in New Issue
Block a user