Merge remote-tracking branch 'origin/dev' into fix/retry-recovery-and-observability
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user