Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 9 additions & 87 deletions src/features/background-agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1424,94 +1424,16 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
continue
}

const messagesResult = await this.client.session.messages({
path: { id: sessionID },
// Session is still actively running (not idle).
// Progress is already tracked via handleEvent(message.part.updated),
// so we skip the expensive session.messages() fetch here.
// Completion will be detected when session transitions to idle.
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus?.type ?? "not_in_status",
toolCalls: task.progress?.toolCalls ?? 0,
})

if (!messagesResult.error && messagesResult.data) {
const messages = messagesResult.data as Array<{
info?: { role?: string }
parts?: Array<{ type?: string; tool?: string; name?: string; text?: string }>
}>
const assistantMsgs = messages.filter(
(m) => m.info?.role === "assistant"
)

let toolCalls = 0
let lastTool: string | undefined
let lastMessage: string | undefined

for (const msg of assistantMsgs) {
const parts = msg.parts ?? []
for (const part of parts) {
if (part.type === "tool_use" || part.tool) {
toolCalls++
lastTool = part.tool || part.name || "unknown"
}
if (part.type === "text" && part.text) {
lastMessage = part.text
}
}
}

if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.toolCalls = toolCalls
task.progress.lastTool = lastTool
task.progress.lastUpdate = new Date()
if (lastMessage) {
task.progress.lastMessage = lastMessage
task.progress.lastMessageAt = new Date()
}

// Stability detection: complete when message count unchanged for 3 polls
const currentMsgCount = messages.length
const startedAt = task.startedAt
if (!startedAt) continue

const elapsedMs = Date.now() - startedAt.getTime()

if (elapsedMs >= MIN_STABILITY_TIME_MS) {
if (task.lastMsgCount === currentMsgCount) {
task.stablePolls = (task.stablePolls ?? 0) + 1
if (task.stablePolls >= 3) {
// Re-fetch session status to confirm agent is truly idle
const recheckStatus = await this.client.session.status()
const recheckData = (recheckStatus.data ?? {}) as Record<string, { type: string }>
const currentStatus = recheckData[sessionID]

if (currentStatus?.type !== "idle") {
log("[background-agent] Stability reached but session not idle, resetting:", {
taskId: task.id,
sessionStatus: currentStatus?.type ?? "not_in_status"
})
task.stablePolls = 0
continue
}

// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Stability reached but no valid output, waiting:", task.id)
continue
}

// Re-check status after async operation
if (task.status !== "running") continue

const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (!hasIncompleteTodos) {
await this.tryCompleteTask(task, "stability detection")
continue
}
}
} else {
task.stablePolls = 0
}
}
task.lastMsgCount = currentMsgCount
}
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/claude-code-hooks/tool-input-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ export function getToolInput(
}

// Periodic cleanup (every minute)
setInterval(() => {
const cleanupInterval = setInterval(() => {
const now = Date.now()
for (const [key, entry] of cache.entries()) {
if (now - entry.timestamp > CACHE_TTL) {
cache.delete(key)
}
}
}, CACHE_TTL)
// Allow process to exit naturally even if interval is running
if (typeof cleanupInterval === "object" && "unref" in cleanupInterval) {
cleanupInterval.unref()
}
102 changes: 102 additions & 0 deletions src/hooks/claude-code-hooks/transcript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import { existsSync, unlinkSync, readFileSync } from "fs"
import {
buildTranscriptFromSession,
deleteTempTranscript,
clearTranscriptCache,
} from "./transcript"

function createMockClient(messages: unknown[] = []) {
return {
session: {
messages: mock(() =>
Promise.resolve({
data: messages,
})
),
},
}
}

describe("transcript caching", () => {
afterEach(() => {
clearTranscriptCache()
})

// #given same session called twice
// #when buildTranscriptFromSession is invoked
// #then session.messages() should be called only once (cached)
it("should cache transcript and not re-fetch for same session", async () => {
const client = createMockClient([
{
info: { role: "assistant" },
parts: [
{
type: "tool",
tool: "bash",
state: { status: "completed", input: { command: "ls" } },
},
],
},
])

const path1 = await buildTranscriptFromSession(
client,
"ses_cache1",
"/tmp",
"bash",
{ command: "echo hi" }
)

const path2 = await buildTranscriptFromSession(
client,
"ses_cache1",
"/tmp",
"read",
{ path: "/tmp/file" }
)

// session.messages() called only once
expect(client.session.messages).toHaveBeenCalledTimes(1)

// Both return valid paths
expect(path1).not.toBeNull()
expect(path2).not.toBeNull()

// Second call should append the new tool entry
if (path2) {
const content = readFileSync(path2, "utf-8")
expect(content).toContain("Read")
}

deleteTempTranscript(path1)
deleteTempTranscript(path2)
})

// #given different sessions
// #when buildTranscriptFromSession called for each
// #then session.messages() should be called for each
it("should not share cache between different sessions", async () => {
const client = createMockClient([])

await buildTranscriptFromSession(client, "ses_a", "/tmp", "bash", {})
await buildTranscriptFromSession(client, "ses_b", "/tmp", "bash", {})

expect(client.session.messages).toHaveBeenCalledTimes(2)

clearTranscriptCache()
})

// #given clearTranscriptCache is called
// #when buildTranscriptFromSession called again
// #then should re-fetch
it("should re-fetch after cache is cleared", async () => {
const client = createMockClient([])

await buildTranscriptFromSession(client, "ses_clear", "/tmp", "bash", {})
clearTranscriptCache()
await buildTranscriptFromSession(client, "ses_clear", "/tmp", "bash", {})

expect(client.session.messages).toHaveBeenCalledTimes(2)
})
})
Loading