Skip to content
Open
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 packages/eve/src/channel/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface RouteHandlerArgs<TState = undefined> {

export interface SendPayload {
readonly message?: string | UserContent;
readonly [key: string]: unknown;
readonly inputResponses?: readonly InputResponse[];
/**
* Context strings contributed by the channel. eve appends each entry
Expand Down
37 changes: 31 additions & 6 deletions packages/eve/src/channel/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import type { ChannelAdapter } from "#channel/adapter.js";
import { createSendFn } from "#channel/send.js";
import type { RunHandle, Runtime } from "#channel/types.js";
import { RuntimeNoActiveSessionError } from "#execution/runtime-errors.js";
import type { MessageStreamEvent } from "#protocol/message.js";
import type { HandleMessageStreamEvent } from "#protocol/message.js";

function createMockRunHandle(): RunHandle {
return {
continuationToken: "test:token",
events: new ReadableStream<MessageStreamEvent>(),
events: new ReadableStream<HandleMessageStreamEvent>(),
sessionId: "mock-session-id",
};
}
Expand All @@ -20,7 +20,7 @@ function createRuntime(deliverError: unknown): Runtime {
deliver: vi.fn().mockRejectedValue(deliverError),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<MessageStreamEvent>()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<HandleMessageStreamEvent>()),
getStreamTailIndex: vi.fn().mockResolvedValue(-1),
terminateSession: vi.fn(),
};
Expand Down Expand Up @@ -77,14 +77,39 @@ describe("createSendFn", () => {
expect(runtime.run).not.toHaveBeenCalled();
});

it("preserves channel-specific fields through delivery", async () => {
const runtime: Runtime = {
cancelTurn: vi.fn(),
deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }),
resolveSession: vi.fn(),
run: vi.fn(),
getEventStream: vi.fn(),
getStreamTailIndex: vi.fn(),
terminateSession: vi.fn(),
};
const send = createSendFn(runtime, ADAPTER, "test");

await send(
{
inputResponses: [{ optionId: "approve", requestId: "approval-1" }],
pendingApprovalCards: { "approval-1": { messageTs: "123.456" } },
},
{ auth: null, continuationToken: "token" },
);

expect(vi.mocked(runtime.deliver).mock.calls[0]?.[0].payload).toMatchObject({
pendingApprovalCards: { "approval-1": { messageTs: "123.456" } },
});
});

it("forwards context through deliver and run payloads", async () => {
const context = ["thread background"];
const deliverRuntime: Runtime = {
cancelTurn: vi.fn(),
deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<MessageStreamEvent>()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<HandleMessageStreamEvent>()),
getStreamTailIndex: vi.fn().mockResolvedValue(-1),
terminateSession: vi.fn(),
};
Expand Down Expand Up @@ -119,7 +144,7 @@ describe("createSendFn", () => {
deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<MessageStreamEvent>()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<HandleMessageStreamEvent>()),
getStreamTailIndex: vi.fn().mockResolvedValue(-1),
terminateSession: vi.fn(),
};
Expand Down Expand Up @@ -149,7 +174,7 @@ describe("createSendFn", () => {
deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<MessageStreamEvent>()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<HandleMessageStreamEvent>()),
getStreamTailIndex: vi.fn().mockResolvedValue(-1),
terminateSession: vi.fn(),
};
Expand Down
3 changes: 2 additions & 1 deletion packages/eve/src/channel/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function createSendFn<TState = undefined>(
inputResponses,
context,
outputSchema,
...channelPayload
} = normalizeSendInput(input);
const message = serializeUrlFilePartsInMessage(rawMessage);

Expand All @@ -39,7 +40,7 @@ export function createSendFn<TState = undefined>(
auth,
continuationToken,
requestId: metadata.requestId,
payload: { inputResponses, message, context, outputSchema },
payload: { ...channelPayload, inputResponses, message, context, outputSchema },
};
const { sessionId } = await runtime.deliver(deliverInput);

Expand Down
14 changes: 7 additions & 7 deletions packages/eve/src/client/message-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ describe("defaultMessageReducer", () => {
kind: "tool-approval",
options: [
{ id: "approve", label: "Yes", style: "primary" },
{ id: "cancel", label: "No", style: "danger" },
{ id: "deny", label: "No", style: "danger" },
],
prompt: "Approve tool call: bash",
requestId: "approval_1",
Expand Down Expand Up @@ -408,7 +408,7 @@ describe("defaultMessageReducer", () => {
kind: "tool-approval",
options: [
{ id: "approve", label: "Yes", style: "primary" },
{ id: "cancel", label: "No", style: "danger" },
{ id: "deny", label: "No", style: "danger" },
],
prompt: "Approve tool call: bash",
requestId: "approval_1",
Expand Down Expand Up @@ -442,7 +442,7 @@ describe("defaultMessageReducer", () => {
kind: "tool-approval",
options: [
{ id: "approve", label: "Yes", style: "primary" },
{ id: "cancel", label: "No", style: "danger" },
{ id: "deny", label: "No", style: "danger" },
],
prompt: "Approve tool call: bash",
requestId: "approval_1",
Expand All @@ -457,7 +457,7 @@ describe("defaultMessageReducer", () => {
data = reducer.reduce(data, {
data: {
createdAt: 1,
responses: [{ optionId: "cancel", requestId: "approval_1" }],
responses: [{ optionId: "deny", requestId: "approval_1" }],
},
type: "client.input.responded",
});
Expand Down Expand Up @@ -487,12 +487,12 @@ describe("defaultMessageReducer", () => {
kind: "tool-approval",
options: [
{ id: "approve", label: "Yes", style: "primary" },
{ id: "cancel", label: "No", style: "danger" },
{ id: "deny", label: "No", style: "danger" },
],
prompt: "Approve tool call: bash",
requestId: "approval_1",
},
inputResponse: { optionId: "cancel", requestId: "approval_1" },
inputResponse: { optionId: "deny", requestId: "approval_1" },
kind: "tool-call",
name: "bash",
},
Expand Down Expand Up @@ -522,7 +522,7 @@ describe("defaultMessageReducer", () => {
kind: "tool-approval",
options: [
{ id: "approve", label: "Yes", style: "primary" },
{ id: "cancel", label: "No", style: "danger" },
{ id: "deny", label: "No", style: "danger" },
],
prompt: "Approve tool call: bash",
requestId: "approval_1",
Expand Down
36 changes: 36 additions & 0 deletions packages/eve/src/client/message-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,42 @@ function reduceMessageData(data: EveMessageData, event: EveAgentReducerEvent): E
return next;
}

case "approval.candidate":
// Candidate progress is responder-specific. Applications can consume the
// raw stream event for private UI without changing the shared tool part.
return data;

case "approval.settled": {
const existing = findToolPartByApprovalId(data, event.data.requestId);
if (existing === undefined) return data;
if (event.data.outcome === "approved") {
return updateToolPart(data, existing.toolCallId, {
approval: { approved: true, id: event.data.requestId, reason: undefined },
input: existing.input,
state: "approval-responded",
stepIndex: existing.stepIndex,
toolCallId: existing.toolCallId,
toolMetadata: existing.toolMetadata,
toolName: existing.toolName,
type: "dynamic-tool",
});
}
return updateToolPart(data, existing.toolCallId, {
approval: {
approved: false,
id: event.data.requestId,
reason: "Tool execution was cancelled.",
},
input: existing.input,
state: "output-denied",
stepIndex: existing.stepIndex,
toolCallId: existing.toolCallId,
toolMetadata: existing.toolMetadata,
toolName: existing.toolName,
type: "dynamic-tool",
});
}

case "action.result": {
const descriptor = normalizeActionResult(event.data.result);
const existing = findToolPart(data, event.data.result.callId);
Expand Down
21 changes: 17 additions & 4 deletions packages/eve/src/execution/workflow-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,19 @@ export async function turnStep(rawInput: TurnStepInput): Promise<DurableStepResu
// the `emit` function is created below.
const pendingAuth = getPendingAuthorization(durableSession.state);
let completedAuths:
| Array<{ name: string; authorization: ConnectionAuthorizationChallenge }>
| Array<{
candidateId?: string;
name: string;
authorization: ConnectionAuthorizationChallenge;
}>
| undefined;
if (pendingAuth && input.input?.kind === "deliver") {
const authResults: Array<{ name: string } & AuthorizationResult> = [];
const completed: Array<{ name: string; authorization: ConnectionAuthorizationChallenge }> = [];
const completed: Array<{
candidateId?: string;
name: string;
authorization: ConnectionAuthorizationChallenge;
}> = [];
const remainingPayloads: DeliverPayload[] = [];
for (const payload of input.input.payloads) {
const cb = payload["authorizationCallback"] as
Expand All @@ -185,7 +193,11 @@ export async function turnStep(rawInput: TurnStepInput): Promise<DurableStepResu
callback: cb.callback,
hookUrl: challenge.hookUrl,
});
completed.push({ name: challenge.name, authorization: challenge.challenge });
completed.push({
candidateId: challenge.candidateId,
name: challenge.name,
authorization: challenge.challenge,
});
}
} else {
remainingPayloads.push(payload);
Expand Down Expand Up @@ -360,10 +372,11 @@ export async function turnStep(rawInput: TurnStepInput): Promise<DurableStepResu
});
if (completedAuths) {
const emissionState = getHarnessEmissionState(schemaSession.state);
for (const { name, authorization } of completedAuths) {
for (const { candidateId, name, authorization } of completedAuths) {
await handleEvent(
createAuthorizationCompletedEvent({
authorization,
candidateId,
name,
outcome: "authorized",
sequence: emissionState.sequence,
Expand Down
56 changes: 56 additions & 0 deletions packages/eve/src/harness/approval-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ApprovalCandidateAuditRecord {
readonly status: ApprovalCandidateStatus;
readonly createdAt: number;
readonly completedAt?: number;
readonly eventEmitted?: boolean;
readonly expiresAt?: number;
readonly authorizationChallenges?: readonly AuthorizationChallenge[];
readonly provider?: string;
Expand All @@ -40,6 +41,7 @@ export interface ApprovalSettlementAuditRecord {
readonly requestId: string;
readonly settledAt: number;
readonly candidateId?: string;
readonly eventEmitted?: boolean;
}

export interface ActiveApprovalCandidate {
Expand All @@ -50,6 +52,7 @@ export interface ActiveApprovalCandidate {
readonly createdAt: number;
readonly expiresAt: number;
readonly authorizationChallenges?: readonly AuthorizationChallenge[];
readonly pendingEventEmitted?: boolean;
readonly provider?: string;
readonly runtimeRevision?: string;
}
Expand Down Expand Up @@ -136,6 +139,59 @@ export function createApprovalCandidate(input: {
};
}

/** Marks the pending candidate event as emitted. */
export function markApprovalCandidatePendingEventEmitted(input: {
readonly candidateId: string;
readonly state: SessionStateMap | undefined;
}): SessionStateMap | undefined {
const approvalState = readApprovalState(input.state);
const candidate = approvalState.activeCandidates[input.candidateId];
if (candidate === undefined || candidate.pendingEventEmitted === true) return input.state;
return writeApprovalState(input.state, {
...approvalState,
activeCandidates: {
...approvalState.activeCandidates,
[input.candidateId]: { ...candidate, pendingEventEmitted: true },
},
});
}

/** Marks a terminal candidate history event as emitted. */
export function markApprovalCandidateHistoryEventEmitted(input: {
readonly candidateId: string;
readonly state: SessionStateMap | undefined;
}): SessionStateMap | undefined {
const approvalState = readApprovalState(input.state);
let changed = false;
const candidateHistory = approvalState.candidateHistory.map((candidate) => {
if (candidate.candidateId !== input.candidateId || candidate.eventEmitted === true) {
return candidate;
}
changed = true;
return { ...candidate, eventEmitted: true };
});
return changed
? writeApprovalState(input.state, { ...approvalState, candidateHistory })
: input.state;
}

/** Marks a terminal settlement event as emitted. */
export function markApprovalSettlementEventEmitted(input: {
readonly requestId: string;
readonly state: SessionStateMap | undefined;
}): SessionStateMap | undefined {
const approvalState = readApprovalState(input.state);
const settlement = approvalState.settlements[input.requestId];
if (settlement === undefined || settlement.eventEmitted === true) return input.state;
return writeApprovalState(input.state, {
...approvalState,
settlements: {
...approvalState.settlements,
[input.requestId]: { ...settlement, eventEmitted: true },
},
});
}

/** Marks a candidate as waiting on a private authorization challenge. */
export function markApprovalCandidateAuthorizationRequired(input: {
readonly authorizationChallenges: readonly AuthorizationChallenge[];
Expand Down
50 changes: 50 additions & 0 deletions packages/eve/src/harness/authorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";

import {
getPendingAuthorization,
setPendingAuthorization,
type AuthorizationChallenge,
} from "#harness/authorization.js";

function challenge(name: string, candidateId: string): AuthorizationChallenge {
return {
candidateId,
challenge: { url: `https://example.com/${name}` },
hookUrl: `https://eve.example/${name}`,
name,
};
}

describe("pending authorization state", () => {
it("merges concurrent candidate challenges by authorization name", () => {
const first = setPendingAuthorization(undefined, {
challenges: [challenge("candidate-1:github", "candidate-1")],
});
const second = setPendingAuthorization(first, {
challenges: [challenge("candidate-2:github", "candidate-2")],
});

expect(getPendingAuthorization(second)?.challenges).toEqual([
expect.objectContaining({ candidateId: "candidate-1", name: "candidate-1:github" }),
expect.objectContaining({ candidateId: "candidate-2", name: "candidate-2:github" }),
]);
});

it("replaces a repeated challenge without duplicating it", () => {
const first = setPendingAuthorization(undefined, {
challenges: [challenge("candidate-1:github", "candidate-1")],
});
const second = setPendingAuthorization(first, {
challenges: [
{
...challenge("candidate-1:github", "candidate-1"),
hookUrl: "https://eve.example/refreshed",
},
],
});

expect(getPendingAuthorization(second)?.challenges).toEqual([
expect.objectContaining({ hookUrl: "https://eve.example/refreshed" }),
]);
});
});
Loading
Loading