feat(llm): add LLMEvent.is.* camelCase narrowing helpers

Schema.toTaggedUnion('type') already provides LLMEvent.guards but uses
kebab-case bracket access (LLMEvent.guards['tool-call']). Adds an LLMEvent.is
namespace with camelCase aliases that delegate to the same guards, so
consumers can write events.filter(LLMEvent.is.toolCall) instead of
events.filter(LLMEvent.guards['tool-call']).

Migrated all callsites in src/llm.ts and the two test files for consistency.
LLMEvent.guards / .match / .cases / .isAnyOf remain available for callers
who want the Effect-canonical API.
This commit is contained in:
Kit Langton
2026-04-28 21:03:53 -04:00
parent a0165b2ae8
commit f4de3e801e
4 changed files with 41 additions and 18 deletions
+3 -3
View File
@@ -191,7 +191,7 @@ export const updateRequest = (input: LLMRequest, patch: Partial<RequestInput>) =
export const outputText = (response: LLMResponse | { readonly events: ReadonlyArray<LLMEvent> }) =>
response.events
.filter(LLMEvent.guards["text-delta"])
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
@@ -204,10 +204,10 @@ export const outputUsage = (response: LLMResponse | { readonly events: ReadonlyA
}
export const outputToolCalls = (response: LLMResponse | { readonly events: ReadonlyArray<LLMEvent> }) =>
response.events.filter(LLMEvent.guards["tool-call"])
response.events.filter(LLMEvent.is.toolCall)
export const outputReasoning = (response: LLMResponse | { readonly events: ReadonlyArray<LLMEvent> }) =>
response.events
.filter(LLMEvent.guards["reasoning-delta"])
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
+25 -2
View File
@@ -338,7 +338,7 @@ export const ProviderErrorEvent = Schema.Struct({
}).annotate({ identifier: "LLM.Event.ProviderError" })
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
export const LLMEvent = Schema.Union([
const llmEventTagged = Schema.Union([
RequestStart,
StepStart,
TextStart,
@@ -353,7 +353,30 @@ export const LLMEvent = Schema.Union([
RequestFinish,
ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type"))
export type LLMEvent = Schema.Schema.Type<typeof LLMEvent>
/**
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
const llmEventIs = {
requestStart: llmEventTagged.guards["request-start"],
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
textEnd: llmEventTagged.guards["text-end"],
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolCall: llmEventTagged.guards["tool-call"],
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
stepFinish: llmEventTagged.guards["step-finish"],
requestFinish: llmEventTagged.guards["request-finish"],
providerError: llmEventTagged.guards["provider-error"],
} as const
export const LLMEvent = Object.assign(llmEventTagged, { is: llmEventIs })
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
export class PatchTrace extends Schema.Class<PatchTrace>("LLM.PatchTrace")({
id: Schema.String,
@@ -44,12 +44,12 @@ describe("OpenAI Chat tool-loop recorded", () => {
// Two model rounds: tool-call + tool-result + final answer. Two
// `request-finish` events confirm both interactions in the cassette
// were dispatched in order.
const finishes = events.filter(LLMEvent.guards["request-finish"])
const finishes = events.filter(LLMEvent.is.requestFinish)
expect(finishes).toHaveLength(2)
expect(finishes[0]?.reason).toBe("tool-calls")
expect(finishes.at(-1)?.reason).toBe("stop")
const toolResult = events.find(LLMEvent.guards["tool-result"])
const toolResult = events.find(LLMEvent.is.toolResult)
expect(toolResult).toMatchObject({
type: "tool-result",
name: "get_weather",
+11 -11
View File
@@ -52,7 +52,7 @@ describe("ToolRuntime", () => {
),
)
const result = events.find(LLMEvent.guards["tool-result"])
const result = events.find(LLMEvent.is.toolResult)
expect(result).toMatchObject({
type: "tool-result",
id: "call_1",
@@ -79,10 +79,10 @@ describe("ToolRuntime", () => {
),
)
const toolError = events.find(LLMEvent.guards["tool-error"])
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
expect(toolError?.message).toContain("Unknown tool")
expect(events.find(LLMEvent.guards["tool-result"])).toMatchObject({
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
type: "tool-result",
id: "call_1",
name: "missing_tool",
@@ -106,7 +106,7 @@ describe("ToolRuntime", () => {
),
)
const toolError = events.find(LLMEvent.guards["tool-error"])
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
expect(toolError?.message).toContain("Invalid tool input")
}),
@@ -127,7 +127,7 @@ describe("ToolRuntime", () => {
),
)
const toolError = events.find(LLMEvent.guards["tool-error"])
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
expect(toolError?.message).toBe("Weather lookup failed for FAIL")
}),
@@ -166,7 +166,7 @@ describe("ToolRuntime", () => {
),
)
expect(events.filter(LLMEvent.guards["request-finish"])).toHaveLength(2)
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2)
}),
)
@@ -186,8 +186,8 @@ describe("ToolRuntime", () => {
}).pipe(Stream.runCollect, Effect.provide(layer)),
)
expect(events.filter(LLMEvent.guards["request-finish"])).toHaveLength(1)
expect(events.find(LLMEvent.guards["tool-result"])).toBeUndefined()
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
expect(events.find(LLMEvent.is.toolResult)).toBeUndefined()
}),
)
@@ -238,8 +238,8 @@ describe("ToolRuntime", () => {
)
expect(streams).toBe(1)
expect(events.find(LLMEvent.guards["tool-error"])).toBeUndefined()
expect(events.filter(LLMEvent.guards["tool-call"])).toEqual([
expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
expect(events.filter(LLMEvent.is.toolCall)).toEqual([
{
type: "tool-call",
id: "srvtoolu_abc",
@@ -276,7 +276,7 @@ describe("ToolRuntime", () => {
),
)
const results = events.filter(LLMEvent.guards["tool-result"])
const results = events.filter(LLMEvent.is.toolResult)
expect(results).toHaveLength(2)
expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
}),