Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/adopt-effect-rstest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@agent-bundle/runtime": patch
"agent-bundle": patch
---

Adopt effect-rstest for Effect-native tests and scoped test resources.
8 changes: 8 additions & 0 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ on the Promise edge. Do not widen public error types to satisfy Effect.
- Host `AbortSignal` at a Promise edge goes on `runPromise(..., { signal })`.
Inside Effect, `interruptWhenAborted` or `yield* scopedAbortSignal`.

## Test helpers

Use `effect-rstest` when a test can return an Effect directly: `it.effect`
provides `TestClock` and `TestConsole`, while `it.live` keeps real services for
host I/O. Use `layer` for a shared service graph and scoped acquisition for
test resources. Keep ordinary rstest tests for Promise-only public APIs and
keep boundary-runner assertions on the package boundary they are testing.

## Streams and concurrency

Stage 2 uses Effect `Stream` for the #145 dispatcher: Flight bytes via
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"@modelcontextprotocol/server": "2.0.0",
"@types/react": "19.2.18",
"@types/ws": "8.18.1",
"effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f",
"react": "19.2.8",
"zod": "4.4.3"
},
Expand Down
125 changes: 68 additions & 57 deletions packages/agent-bundle/tests/event-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,94 @@
import { stat } from 'node:fs/promises';

import { expect, it } from '@rstest/core';
import { Effect } from 'effect';
import { expect, it } from 'effect-rstest';

import {
createEventRuntimeServer,
EventRuntimeTransportError,
requestEventRuntime,
} from '../src/events/ipc.ts';

it('round-trips a bounded event envelope through the epoch-bound runtime socket', async () => {
it.live('round-trips a bounded event envelope through the epoch-bound runtime socket', () => Effect.gen(function*() {
const endpointId = `event-ipc-${crypto.randomUUID()}`;
const server = await createEventRuntimeServer({
artifactEpoch: 'epoch-1',
endpointId,
handle: async (request) => ({
echoed: request.native,
event: request.event,
}),
});

try {
if (process.platform !== 'win32') {
expect((await stat(server.endpoint)).mode & 0o777).toBe(0o600);
}
await expect(requestEventRuntime({
const server = yield* Effect.acquireRelease(
Effect.promise(() => createEventRuntimeServer({
artifactEpoch: 'epoch-1',
endpointId,
event: 'tool/after',
hostContractRevision: '2.1.250',
native: { hook_event_name: 'PostToolUse', tool_name: 'Write' },
signal: new AbortController().signal,
target: 'claude',
timeoutMs: 1_000,
})).resolves.toEqual({
echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' },
event: 'tool/after',
});
} finally {
await server.close();
}
});
handle: async (request) => ({
echoed: request.native,
event: request.event,
}),
})),
(server) => Effect.promise(() => server.close()),
);

it('fails closed on artifact epoch mismatch and missing runtimes', async () => {
const endpointId = `event-ipc-${crypto.randomUUID()}`;
const server = await createEventRuntimeServer({
if (process.platform !== 'win32') {
const endpoint = yield* Effect.promise(() => stat(server.endpoint));
expect(endpoint.mode & 0o777).toBe(0o600);
}
const response = yield* Effect.promise(() => requestEventRuntime({
artifactEpoch: 'epoch-1',
endpointId,
handle: async () => undefined,
event: 'tool/after',
hostContractRevision: '2.1.250',
native: { hook_event_name: 'PostToolUse', tool_name: 'Write' },
signal: new AbortController().signal,
target: 'claude',
timeoutMs: 1_000,
}));
expect(response).toEqual({
echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' },
event: 'tool/after',
});
}));

it.live('fails closed on artifact epoch mismatch and missing runtimes', () => Effect.gen(function*() {
const endpointId = `event-ipc-${crypto.randomUUID()}`;
yield* Effect.scoped(Effect.gen(function*() {
yield* Effect.acquireRelease(
Effect.promise(() => createEventRuntimeServer({
artifactEpoch: 'epoch-1',
endpointId,
handle: async () => undefined,
})),
(server) => Effect.promise(() => server.close()),
);

try {
await expect(requestEventRuntime({
artifactEpoch: 'epoch-2',
const mismatch = yield* Effect.tryPromise({
try: () => requestEventRuntime({
artifactEpoch: 'epoch-2',
endpointId,
event: 'session/start',
hostContractRevision: '2.1.250',
native: { hook_event_name: 'SessionStart' },
signal: new AbortController().signal,
target: 'claude',
timeoutMs: 1_000,
}),
catch: (error) => error,
}).pipe(Effect.flip);
expect(mismatch).toMatchObject({
code: 'epoch-mismatch',
name: EventRuntimeTransportError.name,
});
}));

const unavailable = yield* Effect.tryPromise({
try: () => requestEventRuntime({
artifactEpoch: 'epoch-1',
endpointId,
event: 'session/start',
hostContractRevision: '2.1.250',
native: { hook_event_name: 'SessionStart' },
signal: new AbortController().signal,
target: 'claude',
timeoutMs: 1_000,
})).rejects.toMatchObject({
code: 'epoch-mismatch',
name: EventRuntimeTransportError.name,
});
} finally {
await server.close();
}

await expect(requestEventRuntime({
artifactEpoch: 'epoch-1',
endpointId,
event: 'session/start',
hostContractRevision: '2.1.250',
native: { hook_event_name: 'SessionStart' },
signal: new AbortController().signal,
target: 'claude',
timeoutMs: 100,
})).rejects.toMatchObject({
timeoutMs: 100,
}),
catch: (error) => error,
}).pipe(Effect.flip);
expect(unavailable).toMatchObject({
code: 'runtime-unavailable',
name: EventRuntimeTransportError.name,
});
});
}));
1 change: 1 addition & 0 deletions packages/rsc-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"@rspack/core": "2.2.1",
"@rstest/core": "0.11.10",
"@types/react": "19.2.18",
"effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f",
"react": "19.2.8",
"react-dom": "19.2.8"
}
Expand Down
18 changes: 9 additions & 9 deletions packages/rsc-runtime/tests/agent-document.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Stream } from 'effect';
import { describe, expect, it } from '@rstest/core';
import { Effect, Stream } from 'effect';
import { describe, expect, it } from 'effect-rstest';

import {
Agent,
Expand All @@ -9,7 +9,6 @@ import {
type AgentDocumentNode,
type AgentRenderInvocation,
} from '../src/index.js';
import { runPromise } from '../src/effect/boundary.js';
import { boundRenderEventStream } from '../src/effect/render-stream.js';

const root = (): AgentDocumentNode => ({
Expand Down Expand Up @@ -259,25 +258,26 @@ describe('Agent render events', () => {
});

describe('boundRenderEventStream', () => {
it('assigns sequence numbers and fails closed after complete', async () => {
const events = await runPromise(Stream.runCollect(
it.effect('assigns sequence numbers and fails closed after complete', () => Effect.gen(function*() {
const events = yield* Stream.runCollect(
Stream.make(
{ completed: 0, type: 'progress' as const },
{ completed: 1, type: 'progress' as const },
).pipe(boundRenderEventStream()),
));
);
expect(events.map((event) => event.sequence)).toEqual([0, 1]);

await expect(runPromise(Stream.runCollect(
const failure = yield* Stream.runCollect(
Stream.make(
{
document: { root: root(), status: 'success' as const, version: 1 as const },
type: 'complete' as const,
},
{ completed: 2, type: 'progress' as const },
).pipe(boundRenderEventStream()),
))).rejects.toMatchObject({ code: 'handoff-required' });
});
).pipe(Effect.flip);
expect(failure).toMatchObject({ code: 'handoff-required' });
}));
});

describe('AgentRenderInvocation', () => {
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ packages:
# here instead of through a devDependency in the shipped package.json.
overrides:
'@agent-bundle/runtime': workspace:*
peerDependencyRules:
allowedVersions:
'effect-rstest>@rstest/core': 0.11.10
allowBuilds:
'@google/genai': false
msgpackr-extract: false
Expand Down
Loading