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:
YeonGyu-Kim
2026-03-28 15:24:18 +09:00
parent 44b039bef6
commit d2c576c510
62 changed files with 1264 additions and 292 deletions
@@ -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(() => {
+3 -1
View File
@@ -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()
})
+14 -6
View File
@@ -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)
+10 -4
View File
@@ -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)
+39
View File
@@ -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")
+28 -2
View File
@@ -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", () => {
+10 -11
View File
@@ -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 += `
+1 -1
View File
@@ -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 () => {