Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,17 @@ export function textResponse(text: string): ModelResponse {
return { output, usage: fakeUsage(text.length) };
}

export function toolCallResponse(toolName: string, args: Record<string, unknown>): ModelResponse {
export function toolCallResponse(
toolName: string,
args: Record<string, unknown>,
callId = `call_fake_${toolName}`
): ModelResponse {
const output: AgentOutputItem[] = [
{
type: 'function_call',
name: toolName,
arguments: JSON.stringify(args),
callId: `call_fake_${toolName}`,
callId,
status: 'completed',
},
];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { EnvValueReference, registerEnvValueReference } from '@openai/agents-core/sandbox';

/** A Manifest environment reference that keeps its value in a private field, which only `serialize` exposes. */
export class PrivateEnvValue extends EnvValueReference {
static readonly type = 'test.private-env-value';
readonly #value: string;

constructor(value: string) {
super();
this.#value = value;
}

serialize(): Record<string, unknown> {
return { secret: this.#value };
}

async resolve(): Promise<string> {
return this.#value;
}
}

registerEnvValueReference(PrivateEnvValue, (payload) => new PrivateEnvValue(String(payload.secret)));
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
} from '@openai/agents-core/sandbox';
import { deserializeManifest, mergeManifestDelta, mergeManifestEntryDelta } from '@openai/agents-core/sandbox/internal';

// Re-exported so the Worker can deserialize this reference in sandbox Activities.
export { PrivateEnvValue } from './private-env-value';

export class FakeSandboxSession implements SandboxSession {
state: SandboxSessionState;
execCalls: ExecCommandArgs[] = [];
Expand Down
107 changes: 106 additions & 1 deletion typescript/openai-agents/src/__tests__/test-openai-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
StatelessMCPServerProvider,
} from '..';
import {
approvalResumeContextWorkflow,
localActivityAgentWorkflow,
mcpFactoryArgWorkflow,
summaryOverrideStringWorkflow,
Expand All @@ -25,7 +26,7 @@ import {
streamingNoTopicWorkflow,
} from './workflows/openai-agents';
import { makeTestFunction } from './helpers/test-fn';
import { FakeModelProvider, textResponse, toolCallResponse } from './stubs/openai-agents';
import { FakeModelProvider, handoffResponse, textResponse, toolCallResponse } from './stubs/openai-agents';
import { StreamingFakeModelProvider, streamingTextEvents } from './stubs/openai-agents-fakes';
import EventType = temporal.api.enums.v1.EventType;

Expand Down Expand Up @@ -524,3 +525,107 @@ test('Streaming: useLocalActivity fails fast before scheduling an Activity', asy
t.is(result, 'StreamingLocalActivityUnsupported');
});
});

test('Approval resume keeps the caller context object', async (t) => {
const { createWorker, executeWorkflow } = helpers(t);
const worker = await createWorker({
plugins: [
new OpenAIAgentsPlugin({
modelProvider: new FakeModelProvider([
toolCallResponse('set_after', { value: 'S-1' }, 'call_set_after_1'),
toolCallResponse('set_after', { value: 'S-2' }, 'call_set_after_2'),
toolCallResponse('set_after', { value: 'S-after' }, 'call_set_after_3'),
textResponse('done'),
toolCallResponse('set_after', { value: 'A' }, 'call_shared_a'),
toolCallResponse('set_after', { value: 'B' }, 'call_shared_b'),
toolCallResponse('set_after', { value: 'C' }, 'call_shared_c'),
textResponse('done A'),
textResponse('done B'),
textResponse('done C'),
toolCallResponse('flaky', { value: 'R' }, 'call_flaky'),
textResponse('done R'),
toolCallResponse('gated', { value: 'D' }, 'call_gated_d'),
toolCallResponse('gated', { value: 'E1' }, 'call_gated_e1'),
toolCallResponse('gated', { value: 'E2' }, 'call_gated_e2'),
textResponse('done E'),
toolCallResponse('set_after', { value: 'R-1' }, 'call_rehydrated_1'),
toolCallResponse('set_after', { value: 'R-2' }, 'call_rehydrated_2'),
toolCallResponse('set_after', { value: 'R-3' }, 'call_rehydrated_3'),
textResponse('done rehydrated'),
toolCallResponse('set_after', { value: 'H-1' }, 'call_route_1'),
handoffResponse('transfer_to_FirstTarget'),
textResponse('done routed'),
toolCallResponse('set_after', { value: 'H-2' }, 'call_route_2'),
handoffResponse('transfer_to_FirstTarget'),
toolCallResponse('extend', {}, 'call_extend'),
toolCallResponse('extra', { value: 'X' }, 'call_extra'),
textResponse('done extended'),
handoffResponse('transfer_to_FailChild'),
toolCallResponse('set_after', { value: 'F' }, 'call_fail_child'),
textResponse('done failed conversion'),
toolCallResponse('set_after', { value: 'V' }, 'call_versioned'),
textResponse('done versioned'),
handoffResponse('transfer_to_CustomChild'),
toolCallResponse('set_after', { value: 'C-1' }, 'call_custom_child'),
textResponse('done custom child'),
handoffResponse('transfer_to_ReplacedChild'),
toolCallResponse('set_after', { value: 'C-2' }, 'call_replaced_child'),
textResponse('done replaced child'),
handoffResponse('transfer_to_RestoreChild'),
toolCallResponse('set_after', { value: 'D-1' }, 'call_restore_child'),
textResponse('done restore child'),
toolCallResponse('set_after', { value: 'W' }, 'call_registered'),
textResponse('done registered'),
]),
}),
],
});

const result = await worker.runUntil(
executeWorkflow(approvalResumeContextWorkflow, { workflowExecutionTimeout: '30 seconds' })
);

// Each resume converts into the agent objects its state runs on, so the approvals keep one owner, and
// no resume drops approvals that another state on the context, or a retry of the same state, needs.
t.deepEqual(result, {
callerAfter: 'S-after',
resumedUsesCallerObject: true,
writes: ['S-1', 'S-2', 'S-after'],
approvalRounds: 3,
failedResumeThrew: true,
approvalOwners: 1,
approvedCallIds: 3,
sharedContextWrites: ['A', 'B', 'C'],
sharedContextInterruptions: 0,
retryThrew: true,
retryAttempts: 2,
retryWrites: ['R'],
retryInterruptions: 0,
concurrentFailedResumeThrew: true,
concurrentWrites: ['E1', 'E2'],
concurrentInterruptions: 0,
rehydratedWrites: ['R-1', 'R-2', 'R-3'],
rehydratedApprovalOwners: 2,
rehydratedApprovedCallIds: 6,
handoffTarget: 'SecondTarget',
handoffFilters: ['second'],
disabledHandoffThrew: true,
sharedFieldsWrites: ['X'],
sharedFieldsTemperatures: [0.1, 0.9, 0.9],
failedConversionThrew: true,
failedConversionWrites: ['F'],
failedConversionInterruptions: 0,
versions: ['before', 'after'],
lastAgentVersion: 'after',
customChildWrites: ['C-1'],
customChildInterruptions: 0,
customChildSavedApprovalOwners: 1,
replacedChildWrites: ['C-2'],
replacedChildInterruptions: 0,
failedRestoreThrew: true,
failedRestoreWrites: ['D-1'],
failedRestoreInterruptions: 0,
registeredVersions: ['before', 'after'],
lastAgentRegisteredVersion: 'after',
});
});
40 changes: 40 additions & 0 deletions typescript/openai-agents/src/__tests__/test-openai-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
sandboxApprovalResumeWorkflow,
sandboxArchiveLimitsWorkflow,
sandboxExecWorkflow,
sandboxHandoffConfigResumeWorkflow,
sandboxManifestResumeWorkflow,
sandboxValidationWorkflow,
} from './workflows/openai-sandbox';
Expand Down Expand Up @@ -114,6 +115,45 @@ test.serial('a SandboxAgent whose manifest carries a workerEnvValue survives an
t.true(client.resumeCalls >= 1, 'the preserved session is re-established through resume()');
});

test('a SandboxAgent manifest or runAs change before the resume reaches the sandbox', async (t) => {
const { createWorker, executeWorkflow } = helpers(t);

const client = new FakeSandboxClient();
const responses = () => [
toolCallResponse('confirm', {}),
toolCallResponse('transfer_to_sandbox_target', {}),
toolCallResponse('run_command', { cmd: 'echo hello' }),
textResponse('Done.'),
];
const worker = await createWorker({
plugins: [
new OpenAIAgentsPlugin({
modelProvider: new FakeModelProvider([...responses(), ...responses(), ...responses()]),
modelParams: { startToCloseTimeout: '30s' },
sandboxClientProviders: [new SandboxClientProvider('fake', client)],
}),
],
});

const users = () => client.session.state.manifest.users.map((user) => user.name);
await worker.runUntil(async () => {
t.is(await executeWorkflow(sandboxHandoffConfigResumeWorkflow, { args: ['environment'] }), 'Done.');
t.is(client.createCalls.length, 1);
t.is(client.session.state.environment?.VERSION, 'after');
t.deepEqual(users(), ['before']);

t.is(await executeWorkflow(sandboxHandoffConfigResumeWorkflow, { args: ['runAs'] }), 'Done.');
t.is(client.createCalls.length, 2);
t.is(client.session.state.environment?.VERSION, 'before');
t.deepEqual(users(), ['after']);

t.is(await executeWorkflow(sandboxHandoffConfigResumeWorkflow, { args: ['reference'] }), 'Done.');
t.is(client.createCalls.length, 3);
t.is(client.session.state.environment?.VERSION, 'after');
t.deepEqual(users(), ['before']);
});
});

test('setArchiveLimits is forwarded to the persistWorkspace and hydrateWorkspace Activities', async (t) => {
const { createWorker, executeWorkflow } = helpers(t);

Expand Down
Loading