Merge remote-tracking branch 'origin/dev' into fix/retry-recovery-and-observability

This commit is contained in:
Choi Kijin / 최 기진 / チョイ キジン
2026-04-29 12:25:02 +09:00
13 changed files with 412 additions and 23 deletions
+22
View File
@@ -141,6 +141,22 @@ function extractFilePath(metadata: unknown): string | undefined {
return undefined
}
function extractLineCount(metadata: unknown): number | undefined {
if (!metadata || typeof metadata !== "object") {
return undefined
}
const objectMeta = metadata as Record<string, unknown>
const candidates = [objectMeta.lineCount, objectMeta.linesWritten, objectMeta.lines]
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
return candidate
}
}
return undefined
}
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
return
@@ -151,6 +167,12 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
return
}
const metadataLineCount = extractLineCount(output.metadata)
if (metadataLineCount !== undefined) {
output.output = `${WRITE_SUCCESS_MARKER} ${metadataLineCount} lines written.`
return
}
const filePath = extractFilePath(output.metadata)
if (!filePath) {
return
+22 -2
View File
@@ -11,9 +11,9 @@ function mockCtx(): PluginInput {
return {
client: {} as PluginInput["client"],
directory: "/test",
project: "/test" as unknown as PluginInput["project"],
project: "/test" as PluginInput["project"],
worktree: "/test",
serverUrl: "http://localhost" as unknown as PluginInput["serverUrl"],
serverUrl: "http://localhost" as PluginInput["serverUrl"],
$: {} as PluginInput["$"],
}
}
@@ -238,6 +238,26 @@ describe("hashline-read-enhancer", () => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
it("uses write metadata line count without reading the file", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "write", sessionID: "s", callID: "c" }
const output = {
title: "write",
output: "Wrote file successfully.",
metadata: {
filepath: "/tmp/hashline-metadata-fast-path-missing-file.ts",
lineCount: 7,
},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe("File written successfully. 7 lines written.")
})
it("does not overwrite write tool error output with success message", async () => {
//#given — write tool failed, but stale file exists from previous write
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@@ -43,6 +43,7 @@ interface ClientLike {
export interface AssistantCompactionMessageInfo {
sessionID: string
id?: string
parts?: unknown
}
async function withTimeout<TValue>(
@@ -185,6 +186,7 @@ export function createPostCompactionDegradationMonitor(args: {
sessionID: info.sessionID,
messageID: info.id,
directory,
parts: info.parts,
})
if (!isNoTextTail) {
@@ -46,8 +46,13 @@ export async function resolveNoTextTailFromSession(args: {
sessionID: string
messageID?: string
directory: string
parts?: unknown
}): Promise<boolean> {
const { client, sessionID, messageID, directory } = args
const { client, sessionID, messageID, directory, parts } = args
if (Array.isArray(parts)) {
return isStepOnlyNoTextParts(parts)
}
try {
const response = await client.session.messages({
@@ -192,4 +192,28 @@ describe("preemptive-compaction post-compaction degradation monitor", () => {
// then
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
})
it("uses message update parts without refetching session messages", async () => {
// given
const sessionHistory: AssistantHistoryMessage[] = []
const ctx = createMockCtx(sessionHistory)
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_tail_update_parts"
const stepOnlyParts = [{ type: "step-start" }, { type: "step-finish" }]
await hook.event({
event: {
type: "session.compacted",
properties: { sessionID },
},
})
// when
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_1", parts: stepOnlyParts }))
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_2", parts: stepOnlyParts }))
// then
expect(ctx.client.session.messages).not.toHaveBeenCalled()
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
})
})
+2
View File
@@ -76,6 +76,7 @@ export function createPreemptiveCompactionHook(
modelID?: string
finish?: boolean
tokens?: TokenInfo
parts?: unknown
} | undefined
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
@@ -92,6 +93,7 @@ export function createPreemptiveCompactionHook(
await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID,
id: info.id,
parts: info.parts,
})
}
}
@@ -0,0 +1,99 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { handleSessionIdle } from "./idle-event"
import type { SessionStateStore } from "./session-state"
import type { ContinuationProgressUpdate, SessionState } from "./types"
function createStateStore(): {
store: SessionStateStore
resetCalls: string[]
} {
const state: SessionState = {
stagnationCount: 0,
consecutiveFailures: 0,
}
const resetCalls: string[] = []
const progressUpdate: ContinuationProgressUpdate = {
previousStagnationCount: 0,
stagnationCount: 0,
hasProgressed: false,
progressSource: "none",
}
return {
resetCalls,
store: {
getState: () => state,
getExistingState: () => state,
startPruneInterval: () => {},
recordActivity: () => {},
trackContinuationProgress: () => progressUpdate,
resetContinuationProgress: (sessionID: string) => {
resetCalls.push(sessionID)
},
cancelCountdown: () => {},
cleanup: () => {},
cancelAllCountdowns: () => {},
shutdown: () => {},
},
}
}
describe("handleSessionIdle", () => {
it("resets continuation progress once when todos are empty", async () => {
// given
const sessionID = "ses_empty_todos"
const { store, resetCalls } = createStateStore()
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
}
// when
await handleSessionIdle({
ctx: ctx as never,
sessionID,
sessionStateStore: store,
})
// then
expect(resetCalls).toEqual([sessionID])
})
it("resets continuation progress once when every todo is complete", async () => {
// given
const sessionID = "ses_completed_todos"
const { store, resetCalls } = createStateStore()
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
todo: async () => ({
data: [
{ id: "todo-1", content: "Ship", status: "completed", priority: "high" },
{ id: "todo-2", content: "Verify", status: "completed", priority: "medium" },
],
}),
},
},
directory: "/tmp/test",
}
// when
await handleSessionIdle({
ctx: ctx as never,
sessionID,
sessionStateStore: store,
})
// then
expect(resetCalls).toEqual([sessionID])
})
})
@@ -108,7 +108,6 @@ export async function handleSessionIdle(args: {
}
if (!todos || todos.length === 0) {
sessionStateStore.resetContinuationProgress(sessionID)
sessionStateStore.resetContinuationProgress(sessionID)
log(`[${HOOK_NAME}] No todos`, { sessionID })
return
@@ -116,7 +115,6 @@ export async function handleSessionIdle(args: {
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
sessionStateStore.resetContinuationProgress(sessionID)
sessionStateStore.resetContinuationProgress(sessionID)
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
return