Avoid Date.now call-order flakiness by pinning the mocked current time and setting the message start time explicitly in the test setup. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
354 lines
9.5 KiB
TypeScript
354 lines
9.5 KiB
TypeScript
const { describe, it, expect, spyOn } = require("bun:test")
|
|
import type { RunContext } from "./types"
|
|
import { createEventState } from "./events"
|
|
import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers"
|
|
|
|
const createMockContext = (sessionID: string = "test-session"): RunContext => ({
|
|
sessionID,
|
|
} as RunContext)
|
|
|
|
describe("handleSessionStatus", () => {
|
|
it("recognizes idle from session.status event (not just deprecated session.idle)", () => {
|
|
//#given - state with mainSessionIdle=false
|
|
const ctx = createMockContext("test-session")
|
|
const state = createEventState()
|
|
state.mainSessionIdle = false
|
|
|
|
const payload = {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "test-session",
|
|
status: { type: "idle" as const },
|
|
},
|
|
}
|
|
|
|
//#when - handleSessionStatus called with idle status
|
|
handleSessionStatus(ctx, payload as any, state)
|
|
|
|
//#then - state.mainSessionIdle === true
|
|
expect(state.mainSessionIdle).toBe(true)
|
|
})
|
|
|
|
it("handleSessionStatus sets idle=false on busy", () => {
|
|
//#given - state with mainSessionIdle=true
|
|
const ctx = createMockContext("test-session")
|
|
const state = createEventState()
|
|
state.mainSessionIdle = true
|
|
|
|
const payload = {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "test-session",
|
|
status: { type: "busy" as const },
|
|
},
|
|
}
|
|
|
|
//#when - handleSessionStatus called with busy status
|
|
handleSessionStatus(ctx, payload as any, state)
|
|
|
|
//#then - state.mainSessionIdle === false
|
|
expect(state.mainSessionIdle).toBe(false)
|
|
})
|
|
|
|
it("does nothing for different session ID", () => {
|
|
//#given - state with mainSessionIdle=true
|
|
const ctx = createMockContext("test-session")
|
|
const state = createEventState()
|
|
state.mainSessionIdle = true
|
|
|
|
const payload = {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "other-session",
|
|
status: { type: "idle" as const },
|
|
},
|
|
}
|
|
|
|
//#when - handleSessionStatus called with different session ID
|
|
handleSessionStatus(ctx, payload as any, state)
|
|
|
|
//#then - state.mainSessionIdle remains unchanged
|
|
expect(state.mainSessionIdle).toBe(true)
|
|
})
|
|
|
|
it("recognizes idle from camelCase sessionId", () => {
|
|
//#given - state with mainSessionIdle=false and payload using sessionId
|
|
const ctx = createMockContext("test-session")
|
|
const state = createEventState()
|
|
state.mainSessionIdle = false
|
|
|
|
const payload = {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionId: "test-session",
|
|
status: { type: "idle" as const },
|
|
},
|
|
}
|
|
|
|
//#when - handleSessionStatus called with camelCase sessionId
|
|
handleSessionStatus(ctx, payload as any, state)
|
|
|
|
//#then - state.mainSessionIdle === true
|
|
expect(state.mainSessionIdle).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("handleMessagePartUpdated", () => {
|
|
it("extracts sessionID from part (current OpenCode event structure)", () => {
|
|
//#given - message.part.updated with sessionID in part, not info
|
|
const ctx = createMockContext("ses_main")
|
|
const state = createEventState()
|
|
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
|
|
|
|
const payload = {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "part_1",
|
|
sessionID: "ses_main",
|
|
messageID: "msg_1",
|
|
type: "text",
|
|
text: "Hello world",
|
|
},
|
|
},
|
|
}
|
|
|
|
//#when
|
|
handleMessagePartUpdated(ctx, payload as any, state)
|
|
|
|
//#then
|
|
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
|
expect(state.lastPartText).toBe("Hello world")
|
|
expect(stdoutSpy).toHaveBeenCalled()
|
|
stdoutSpy.mockRestore()
|
|
})
|
|
|
|
it("skips events for different session", () => {
|
|
//#given - message.part.updated with different session
|
|
const ctx = createMockContext("ses_main")
|
|
const state = createEventState()
|
|
|
|
const payload = {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "part_1",
|
|
sessionID: "ses_other",
|
|
messageID: "msg_1",
|
|
type: "text",
|
|
text: "Hello world",
|
|
},
|
|
},
|
|
}
|
|
|
|
//#when
|
|
handleMessagePartUpdated(ctx, payload as any, state)
|
|
|
|
//#then
|
|
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
|
expect(state.lastPartText).toBe("")
|
|
})
|
|
|
|
it("handles tool part with running status", () => {
|
|
//#given - tool part in running state
|
|
const ctx = createMockContext("ses_main")
|
|
const state = createEventState()
|
|
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
|
|
|
|
const payload = {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "part_1",
|
|
sessionID: "ses_main",
|
|
messageID: "msg_1",
|
|
type: "tool",
|
|
tool: "read",
|
|
state: { status: "running", input: { filePath: "/src/index.ts" } },
|
|
},
|
|
},
|
|
}
|
|
|
|
//#when
|
|
handleMessagePartUpdated(ctx, payload as any, state)
|
|
|
|
//#then
|
|
expect(state.currentTool).toBe("read")
|
|
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
|
stdoutSpy.mockRestore()
|
|
})
|
|
|
|
it("clears currentTool when tool completes", () => {
|
|
//#given - tool part in completed state
|
|
const ctx = createMockContext("ses_main")
|
|
const state = createEventState()
|
|
state.currentTool = "read"
|
|
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
|
|
|
|
const payload = {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "part_1",
|
|
sessionID: "ses_main",
|
|
messageID: "msg_1",
|
|
type: "tool",
|
|
tool: "read",
|
|
state: { status: "completed", input: {}, output: "file contents here" },
|
|
},
|
|
},
|
|
}
|
|
|
|
//#when
|
|
handleMessagePartUpdated(ctx, payload as any, state)
|
|
|
|
//#then
|
|
expect(state.currentTool).toBeNull()
|
|
stdoutSpy.mockRestore()
|
|
})
|
|
|
|
it("supports legacy info.sessionID for backward compatibility", () => {
|
|
//#given - legacy event with sessionID in info
|
|
const ctx = createMockContext("ses_legacy")
|
|
const state = createEventState()
|
|
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
|
|
|
|
const payload = {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
info: { sessionID: "ses_legacy", role: "assistant" },
|
|
part: {
|
|
type: "text",
|
|
text: "Legacy text",
|
|
},
|
|
},
|
|
}
|
|
|
|
//#when
|
|
handleMessagePartUpdated(ctx, payload as any, state)
|
|
|
|
//#then
|
|
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
|
expect(state.lastPartText).toBe("Legacy text")
|
|
stdoutSpy.mockRestore()
|
|
})
|
|
|
|
it("prints completion metadata once when assistant text part is completed", () => {
|
|
// given
|
|
const nowSpy = spyOn(Date, "now").mockReturnValue(3400)
|
|
|
|
const ctx = createMockContext("ses_main")
|
|
const state = createEventState()
|
|
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
|
|
|
|
handleMessageUpdated(
|
|
ctx,
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_1",
|
|
sessionID: "ses_main",
|
|
role: "assistant",
|
|
agent: "Sisyphus",
|
|
modelID: "claude-sonnet-4-6",
|
|
},
|
|
},
|
|
} as any,
|
|
state,
|
|
)
|
|
state.messageStartedAtById["msg_1"] = 1000
|
|
|
|
// when
|
|
handleMessagePartUpdated(
|
|
ctx,
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "part_1",
|
|
sessionID: "ses_main",
|
|
messageID: "msg_1",
|
|
type: "text",
|
|
text: "done",
|
|
time: { end: 1 },
|
|
},
|
|
},
|
|
} as any,
|
|
state,
|
|
)
|
|
|
|
handleMessagePartUpdated(
|
|
ctx,
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "part_1",
|
|
sessionID: "ses_main",
|
|
messageID: "msg_1",
|
|
type: "text",
|
|
text: "done",
|
|
time: { end: 2 },
|
|
},
|
|
},
|
|
} as any,
|
|
state,
|
|
)
|
|
|
|
// then
|
|
const output = stdoutSpy.mock.calls.map(call => String(call[0])).join("")
|
|
const metaCount = output.split("Sisyphus · claude-sonnet-4-6 · 2.4s").length - 1
|
|
expect(metaCount).toBe(1)
|
|
expect(state.completionMetaPrintedByMessageId["msg_1"]).toBe(true)
|
|
|
|
stdoutSpy.mockRestore()
|
|
nowSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe("handleTuiToast", () => {
|
|
it("marks main session as error when toast variant is error", () => {
|
|
//#given - toast error payload
|
|
const ctx = createMockContext("test-session")
|
|
const state = createEventState()
|
|
|
|
const payload = {
|
|
type: "tui.toast.show",
|
|
properties: {
|
|
title: "Auth",
|
|
message: "Invalid API key",
|
|
variant: "error" as const,
|
|
},
|
|
}
|
|
|
|
//#when
|
|
handleTuiToast(ctx, payload as any, state)
|
|
|
|
//#then
|
|
expect(state.mainSessionError).toBe(true)
|
|
expect(state.lastError).toBe("Auth: Invalid API key")
|
|
})
|
|
|
|
it("does not mark session error for warning toast", () => {
|
|
//#given - toast warning payload
|
|
const ctx = createMockContext("test-session")
|
|
const state = createEventState()
|
|
|
|
const payload = {
|
|
type: "tui.toast.show",
|
|
properties: {
|
|
message: "Retrying provider",
|
|
variant: "warning" as const,
|
|
},
|
|
}
|
|
|
|
//#when
|
|
handleTuiToast(ctx, payload as any, state)
|
|
|
|
//#then
|
|
expect(state.mainSessionError).toBe(false)
|
|
expect(state.lastError).toBe(null)
|
|
})
|
|
})
|