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
1 change: 1 addition & 0 deletions api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7161,6 +7161,7 @@ export function webhookChannel(options?: WebhookChannelOptions): WebhookChannel;
// @public
export interface WebhookChannelOptions {
auth?: WebhookAuth;
fullResult?: boolean;
name?: string;
principal?: (body: unknown, req: ChannelRequest) => Principal | undefined;
secret?: string;
Expand Down
22 changes: 21 additions & 1 deletion src/channels/webhookChannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,27 @@ describe('webhookChannel auth (test vectors)', () => {
expect(res.json).toMatchObject({ finishReason: 'awaiting-approval' });
expect(res.json.approvalPrompt).toContain('Approve restart_service');
expect(res.json.approvalPrompt).toContain('checkout');
expect(res.json.approvalPrompt).toContain(String(res.json.approvalId));
expect(res.json.approvalPrompt).toContain((res.json.approval as { id: string }).id);
expect(res.json).not.toHaveProperty('messages');
});

it('replies with a trimmed body by default: no transcript, system prompt or usage', async () => {
const agent = createAgent({ provider: mockModel(['hi there']), instructions: 'SECRET-SYSTEM-PROMPT' });
const res = await viaChannel(webhookChannel({}), body, {}, agent);

expect(res.status).toBe(200);
expect(res.json).toMatchObject({ text: 'hi there', finishReason: 'stop' });
expect(typeof res.json.sessionId).toBe('string');
for (const key of ['messages', 'stepUsage', 'usage', 'toolCalls']) expect(res.json).not.toHaveProperty(key);
expect(JSON.stringify(res.json)).not.toContain('SECRET-SYSTEM-PROMPT');
});

it('`fullResult: true` restores the whole ExecutionResult', async () => {
const agent = createAgent({ provider: mockModel(['hi there']), instructions: 'SECRET-SYSTEM-PROMPT' });
const res = await viaChannel(webhookChannel({ fullResult: true }), body, {}, agent);

expect(res.json).toHaveProperty('messages');
expect(JSON.stringify(res.json)).toContain('SECRET-SYSTEM-PROMPT');
});

it('a body without what `principal` needs runs without one', async () => {
Expand Down
25 changes: 21 additions & 4 deletions src/channels/webhookChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export interface WebhookChannelOptions {
* `req` carries the headers. Set it only from data `auth` vouched for.
*/
principal?: (body: unknown, req: ChannelRequest) => Principal | undefined;
/**
* Reply with the turn's whole `ExecutionResult` (messages, tool calls,
* usage) instead of the trimmed default `{ sessionId, text, finishReason,
* approval }`. The full result carries the system prompt and the whole
* transcript, so enable it only when every caller is trusted with them.
* Default `false`.
*/
fullResult?: boolean;
}

/** A webhook channel: `verify` (set when `secret` or `auth` is) resolves a `ChannelAuthResult`. */
Expand Down Expand Up @@ -64,7 +72,8 @@ async function verifyWebhook(auth: WebhookAuth | undefined, req: ChannelRequest)

/**
* A generic webhook: the body's JSON `input` string (or the whole body) is
* the input, and the response is the turn's `ExecutionResult` as JSON. Each request is a one-shot session unless the
* the input, and the response is JSON `{ sessionId, text, finishReason, approval? }` (the whole
* `ExecutionResult` with `fullResult: true`). Each request is a one-shot session unless the
* JSON body has a `sessionKey` string. With `secret` or `auth`, a request
* that fails the check gets 401. `principal` names the caller of the turn
* (an alerting system, an integration user) from the verified request.
Expand Down Expand Up @@ -96,12 +105,20 @@ export function webhookChannel(options: WebhookChannelOptions = {}): WebhookChan
const principal = options.principal?.(body, req);
return { sessionKey: typeof sessionKey === 'string' && sessionKey ? sessionKey : newId(), input, ...(principal && { principal }), replyTo: null };
},
async reply({ text, result, respond }) {
async reply({ sessionId, text, result, approval, approvals, respond }) {
// On an approval pause `result.text` is empty (the last turn was a bare
// tool call), so the human-readable prompt the channel computed is sent
// too: a consumer can render it instead of rebuilding it from `approvalId`.
if (result?.finishReason === 'awaiting-approval') return respond?.(200, { ...result, approvalPrompt: text });
respond?.(200, result ?? { text });
const paused = result?.finishReason === 'awaiting-approval';
if (options.fullResult) return respond?.(200, paused ? { ...result, approvalPrompt: text } : (result ?? { text }));
respond?.(200, {
sessionId,
text,
finishReason: result?.finishReason,
...(approval && { approval }),
...(approvals && { approvals }),
...(paused && { approvalPrompt: text }),
});
},
};
defineChannel(channel);
Expand Down
Loading