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
63 changes: 35 additions & 28 deletions examples/rsc-agent-runtime/src/events/tool/after.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Agent } from '@agent-bundle/runtime';
import type { AgentEventRouteProps } from 'agent-bundle';
import * as React from 'react';

import { writeEvalProbe } from '../../hook/eval-probe.js';
import { normalizeClaudeHook, normalizeCodexHook } from '../../hook/normalize.js';
import { createFileRuntimeKernel, resolveImplicitRuntimeStateFile } from '../../runtime/state-file.js';

Expand All @@ -19,34 +20,40 @@ export default async function AfterFileEdit({
native,
signal,
}: AgentEventRouteProps) {
const host = canonical.provenance.host;
const normalized = host === 'claude'
? normalizeClaudeHook(native)
: host === 'codex'
? normalizeCodexHook(native)
: undefined;
if (normalized === undefined) {
throw new Error(`Unsupported event-route host ${JSON.stringify(host)}`);
}
try {
const host = canonical.provenance.host;
const normalized = host === 'claude'
? normalizeClaudeHook(native)
: host === 'codex'
? normalizeCodexHook(native)
: undefined;
if (normalized === undefined) {
throw new Error(`Unsupported event-route host ${JSON.stringify(host)}`);
}

const configuredStateFile = process.env.AGENT_RUNTIME_STATE_FILE;
const stateFile = configuredStateFile === undefined || configuredStateFile.trim() === ''
? await resolveImplicitRuntimeStateFile(normalized.cwd)
: resolve(configuredStateFile);
const snapshot = await createFileRuntimeKernel({ stateFile }).recordEdit({
host: normalized.host,
idempotencyKey: canonical.idempotencyKey,
path: normalized.path,
sessionId: normalized.sessionId,
toolName: normalized.toolName,
}, { signal });
const editNoun = snapshot.stateVersion === 1 ? 'edit' : 'edits';
const configuredStateFile = process.env.AGENT_RUNTIME_STATE_FILE;
const stateFile = configuredStateFile === undefined || configuredStateFile.trim() === ''
? await resolveImplicitRuntimeStateFile(normalized.cwd)
: resolve(configuredStateFile);
const snapshot = await createFileRuntimeKernel({ stateFile }).recordEdit({
host: normalized.host,
idempotencyKey: canonical.idempotencyKey,
path: normalized.path,
sessionId: normalized.sessionId,
toolName: normalized.toolName,
}, { signal });
const editNoun = snapshot.stateVersion === 1 ? 'edit' : 'edits';
await writeEvalProbe(native, 0);

return (
<Agent.Result>
<Agent.Context>
{`Recorded ${basename(normalized.path)} from ${normalized.host}. Shared state now contains ${snapshot.stateVersion} ${editNoun}.`}
</Agent.Context>
</Agent.Result>
);
return (
<Agent.Result>
<Agent.Context>
{`Recorded ${basename(normalized.path)} from ${normalized.host}. Shared state now contains ${snapshot.stateVersion} ${editNoun}.`}
</Agent.Context>
</Agent.Result>
);
} catch (error) {
await writeEvalProbe(native, 1).catch(() => undefined);
throw error;
}
}
29 changes: 1 addition & 28 deletions examples/rsc-agent-runtime/src/hook/cli.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,13 @@
import { appendFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import { requestAgentDocument } from '../flight/request-render.js';
import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js';
import { writeEvalProbe } from './eval-probe.js';
import { normalizeClaudeHook, normalizeCodexHook } from './normalize.js';
import { projectHookDocument } from './project-document.js';

let probeInput: Record<string, unknown> | undefined;

const valueType = (value: unknown): string => {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
};

const writeEvalProbe = async (input: Record<string, unknown>, exitStatus: number): Promise<void> => {
const probeFile = process.env.AGENT_RUNTIME_HOOK_PROBE_FILE;
if (probeFile === undefined || probeFile.trim() === '') return;

const toolInput = input.tool_input;
const toolInputRecord = toolInput !== null && typeof toolInput === 'object' && !Array.isArray(toolInput)
? toolInput as Record<string, unknown>
: undefined;
const topLevelKeys = Object.keys(input).sort();
const toolInputKeys = toolInputRecord === undefined ? [] : Object.keys(toolInputRecord).sort();
await appendFile(probeFile, `${JSON.stringify({
commandLaunched: true,
exitStatus,
toolInputKeys,
toolInputValueTypes: Object.fromEntries(toolInputKeys.map((key) => [key, valueType(toolInputRecord?.[key])])),
toolName: typeof input.tool_name === 'string' ? input.tool_name : undefined,
topLevelKeys,
topLevelValueTypes: Object.fromEntries(topLevelKeys.map((key) => [key, valueType(input[key])])),
})}\n`);
};

const readInput = async (): Promise<Record<string, unknown>> => {
let contents = '';
process.stdin.setEncoding('utf8');
Expand Down
28 changes: 28 additions & 0 deletions examples/rsc-agent-runtime/src/hook/eval-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { appendFile } from 'node:fs/promises';

const valueType = (value: unknown): string => {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
};

export const writeEvalProbe = async (input: Record<string, unknown>, exitStatus: number): Promise<void> => {
const probeFile = process.env.AGENT_RUNTIME_HOOK_PROBE_FILE;
if (probeFile === undefined || probeFile.trim() === '') return;

const toolInput = input.tool_input;
const toolInputRecord = toolInput !== null && typeof toolInput === 'object' && !Array.isArray(toolInput)
? toolInput as Record<string, unknown>
: undefined;
const topLevelKeys = Object.keys(input).sort();
const toolInputKeys = toolInputRecord === undefined ? [] : Object.keys(toolInputRecord).sort();
await appendFile(probeFile, `${JSON.stringify({
commandLaunched: true,
exitStatus,
toolInputKeys,
toolInputValueTypes: Object.fromEntries(toolInputKeys.map((key) => [key, valueType(toolInputRecord?.[key])])),
toolName: typeof input.tool_name === 'string' ? input.tool_name : undefined,
topLevelKeys,
topLevelValueTypes: Object.fromEntries(topLevelKeys.map((key) => [key, valueType(input[key])])),
})}\n`);
};
49 changes: 49 additions & 0 deletions examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@ const fixture = resolve(import.meta.dirname, '../fixtures/events/claude-post-too

let workspace: string;
let previousStateFile: string | undefined;
let previousProbeFile: string | undefined;

beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-event-route-'));
previousStateFile = process.env.AGENT_RUNTIME_STATE_FILE;
previousProbeFile = process.env.AGENT_RUNTIME_HOOK_PROBE_FILE;
process.env.AGENT_RUNTIME_STATE_FILE = join(workspace, 'state.json');
});

afterEach(async () => {
if (previousStateFile === undefined) delete process.env.AGENT_RUNTIME_STATE_FILE;
else process.env.AGENT_RUNTIME_STATE_FILE = previousStateFile;
if (previousProbeFile === undefined) delete process.env.AGENT_RUNTIME_HOOK_PROBE_FILE;
else process.env.AGENT_RUNTIME_HOOK_PROBE_FILE = previousProbeFile;
await rm(workspace, { force: true, recursive: true });
});

Expand Down Expand Up @@ -66,3 +70,48 @@ it('renders a native Claude PostToolUse envelope into the document the host proj
.toContainContext('Recorded claude-note.txt from claude. Shared state now contains 1 edit.');
expect(rendered.provenance).toMatchObject({ kind: 'event-route', proofLevel: 'route-unit' });
});

it('appends a value-free eval hook probe when AGENT_RUNTIME_HOOK_PROBE_FILE is set', async () => {
const probeFile = join(workspace, 'hook-probe.jsonl');
process.env.AGENT_RUNTIME_HOOK_PROBE_FILE = probeFile;
const native = JSON.parse(await readFile(fixture, 'utf8')) as Record<string, unknown>;

await renderRoute('event:tool/after', {
input: {
canonical: {
event: 'tool/after',
idempotencyKey: 'route-unit-claude-write',
observedAt: '2026-09-01T00:00:00.000Z',
provenance: {
host: 'claude',
hostContractRevision: 'route-unit',
nativeEvent: 'PostToolUse',
source: 'native',
},
sequence: 1,
},
native: { ...native, cwd: workspace },
},
});

const probe = JSON.parse(await readFile(probeFile, 'utf8'));
expect(probe).toEqual({
commandLaunched: true,
exitStatus: 0,
toolInputKeys: ['file_path'],
toolInputValueTypes: { file_path: 'string' },
toolName: 'Write',
topLevelKeys: ['cwd', 'hook_event_name', 'session_id', 'tool_input', 'tool_name', 'tool_response', 'tool_use_id', 'transcript_path'],
topLevelValueTypes: {
cwd: 'string',
hook_event_name: 'string',
session_id: 'string',
tool_input: 'object',
tool_name: 'string',
tool_response: 'object',
tool_use_id: 'string',
transcript_path: 'string',
},
});
expect(await readFile(probeFile, 'utf8')).not.toContain('claude-note.txt');
});
Loading