runForEach :: (A → Effect<R, E>) → Stream<A, E, R> → Effect<Unit, E, R>
runDrain :: Stream<A, E, R> → Effect<Unit, E, R>
runLast :: Stream<A, E, R> → Effect<Option, E, R>
stream.pipe(
Stream.runForEach((part) =>
Match.value(part).pipe(
Match.when({ type: 'text-delta' }, ({ delta }) => updateUI(delta)),
Match.when({ type: 'finish' }, ({ usage }) => recordMetrics(usage)),
Match.orElse(() => Effect.void)
)
)
);
stream.pipe(Stream.tap(logPart), Stream.runDrain);
stream.pipe(
Stream.runFold(initialState, (acc, part) => merge(acc, part)),
Effect.map(Option.some)
);
History Update Pattern
Incremental merge strategy for conversation history:
Prompt.concat :: Prompt → Prompt → Prompt
Prompt.fromResponseParts :: Array<StreamPart> → Prompt
const accumulated: Array<StreamPart> = []
let combined = Prompt.empty
Stream.mapChunksEffect(function* (chunk) {
const parts = Array.from(chunk)
accumulated.push(...parts)
combined = Prompt.fromResponseParts(accumulated)
yield* SubscriptionRef.set(
history,
Prompt.concat(filteredCheckpoint, combined)
)
return chunk
})
Why checkpoint-based merging:
- Prevents re-merging entire history on each chunk
- Separates base state (checkpoint) from streaming accumulation (combined)
- Enables atomic history updates via SubscriptionRef
- Ensures
Prompt.fromResponseParts sees matching start/delta/end parts for each id
Tool Streaming, Finish, and Approvals
- With automatic framework tool resolution enabled,
finish is deferred until tool handler streams complete so emitted tool results appear before finish.
tool-result parts can be preliminary or final. Use preliminary results for progress updates only; Prompt.fromResponseParts skips preliminary results and persists final results.
- Tools requiring approval emit
tool-approval-request. Append a matching Prompt.toolApprovalResponsePart in a tool message and call the model again; approved/denied responses are pre-resolved into final tool results before the next provider call.
- In OpenAI-specific SSE code, unknown future events decode through
OpenAiSchema.ResponseStreamEvent and are ignored by OpenAiLanguageModel; malformed known events still fail decoding.
Complete Example
import * as Prompt from 'effect/unstable/ai/Prompt';
import * as Response from 'effect/unstable/ai/Response';
import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
import * as Stream from 'effect/Stream';
import * as Effect from 'effect/Effect';
import * as SubscriptionRef from 'effect/SubscriptionRef';
import * as Semaphore from 'effect/Semaphore';
import * as Match from 'effect/Match';
const Chat = Effect.gen(function* () {
const history = yield* SubscriptionRef.make(Prompt.empty);
const semaphore = yield* Semaphore.make(1);
const streamText = (prompt: string) =>
Stream.fromChannel(
Channel.acquireUseRelease(
semaphore.take(1).pipe(
Effect.zipRight(SubscriptionRef.get(history)),
Effect.map((hist) =>
Prompt.concat(hist, Prompt.make(prompt))
),
Effect.tap((checkpoint) => {
combined = Prompt.empty;
return SubscriptionRef.set(history, checkpoint);
})
),
(checkpoint) => {
let combined = Prompt.empty;
const accumulated: Array<Response.StreamPart> = [];
return LanguageModel.streamText({
prompt: checkpoint
}).pipe(
Stream.mapChunksEffect(
Effect.fnUntraced(function* (chunk) {
const parts = Array.from(chunk);
accumulated.push(...parts);
combined = Prompt.fromResponseParts(accumulated);
yield* SubscriptionRef.set(
history,
Prompt.concat(checkpoint, combined)
);
return chunk;
})
),
Stream.toChannel
);
},
() => semaphore.release(1)
)
);
return { streamText };
});
chat.streamText('Hello').pipe(
Stream.runForEach((part) =>
Match.value(part).pipe(
Match.when({ type: 'text-delta' }, ({ delta }) =>
Effect.sync(() => console.log(delta))
),
Match.when({ type: 'finish' }, ({ usage }) =>
Effect.sync(() => console.log(usage))
),
Match.orElse(() => Effect.void)
)
)
);
Anti-Patterns
Effect.either(effect).pipe(
Effect.map((result) => result._tag === "Left" ? ... : ...)
)
effect.pipe(
Effect.match({
onFailure: (error) => ...,
onSuccess: (value) => ...
})
)
Match.value(part).pipe(Match.tag("text-delta", handler))
Match.value(part).pipe(Match.when({ type: "text-delta" }, handler))
if (part.type === "text-delta") { handler(part) }
Stream.map((chunk) => {
accumulated.push(...chunk)
return chunk
})
Stream.mapChunksEffect(Effect.fnUntraced(function* (chunk) {
accumulated.push(...chunk)
yield* updateHistory()
return chunk
}))
Additional Stream Part Types
File Parts
{ type: "file", mediaType: "image/png", data: Uint8Array }
Source Parts
{ type: "document-source", id: string, title?: string }
{ type: "url-source", url: string, title?: string }
Metadata Parts
{ type: "response-metadata", id: string, modelId: string, timestamp: Date }
Error Parts
{ type: "error", error: AiError }
Match.when({ type: "error" }, ({ error }) => Effect.fail(error))
Quality Checklist
Related Skills
- effect-ai-language-model - streamText method that produces these streams
- effect-ai-prompt - Converting stream responses to history with fromResponseParts
- effect-ai-tool - Tool call streaming parts
- effect-ai-provider - Provider-specific streaming behavior
Reference
StreamPart types:
text-start, text-delta, text-end - Text content streaming
reasoning-start, reasoning-delta, reasoning-end - Chain-of-thought streaming
tool-params-start, tool-params-delta, tool-params-end - Tool parameter streaming
tool-call - Complete tool invocation (non-streaming)
tool-result - Tool execution result
finish - Stream completion with usage stats
error - Error part
Key modules:
effect/unstable/ai/Response - Response part schemas and constructors
effect/unstable/ai/Prompt - Prompt construction and merging
effect/Stream - Stream combinators (mapChunksEffect, runForEach, runDrain)
effect/Channel - Low-level resource management (acquireUseRelease)
effect/SubscriptionRef - Reactive shared state
effect/Match - Pattern matching (use Match.when({ type: ... }) for stream parts)