@northflare/runner 0.0.16 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/codex-sdk/dist/src/codex.d.ts +27 -0
- package/lib/codex-sdk/dist/src/codex.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/codex.js +33 -0
- package/lib/codex-sdk/dist/src/codex.js.map +1 -0
- package/lib/codex-sdk/dist/src/codexOptions.d.ts +11 -0
- package/lib/codex-sdk/dist/src/codexOptions.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/codexOptions.js +2 -0
- package/lib/codex-sdk/dist/src/codexOptions.js.map +1 -0
- package/lib/codex-sdk/dist/src/events.d.ts +60 -0
- package/lib/codex-sdk/dist/src/events.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/events.js +3 -0
- package/lib/codex-sdk/dist/src/events.js.map +1 -0
- package/lib/codex-sdk/dist/src/exec.d.ts +27 -0
- package/lib/codex-sdk/dist/src/exec.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/exec.js +264 -0
- package/lib/codex-sdk/dist/src/exec.js.map +1 -0
- package/lib/codex-sdk/dist/src/index.d.ts +9 -0
- package/lib/codex-sdk/dist/src/index.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/index.js +3 -0
- package/lib/codex-sdk/dist/src/index.js.map +1 -0
- package/lib/codex-sdk/dist/src/items.d.ts +103 -0
- package/lib/codex-sdk/dist/src/items.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/items.js +3 -0
- package/lib/codex-sdk/dist/src/items.js.map +1 -0
- package/lib/codex-sdk/dist/src/outputSchemaFile.d.ts +6 -0
- package/lib/codex-sdk/dist/src/outputSchemaFile.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/outputSchemaFile.js +33 -0
- package/lib/codex-sdk/dist/src/outputSchemaFile.js.map +1 -0
- package/lib/codex-sdk/dist/src/thread.d.ts +41 -0
- package/lib/codex-sdk/dist/src/thread.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/thread.js +109 -0
- package/lib/codex-sdk/dist/src/thread.js.map +1 -0
- package/lib/codex-sdk/dist/src/threadOptions.d.ts +16 -0
- package/lib/codex-sdk/dist/src/threadOptions.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/threadOptions.js +2 -0
- package/lib/codex-sdk/dist/src/threadOptions.js.map +1 -0
- package/lib/codex-sdk/dist/src/turnOptions.d.ts +7 -0
- package/lib/codex-sdk/dist/src/turnOptions.d.ts.map +1 -0
- package/lib/codex-sdk/dist/src/turnOptions.js +2 -0
- package/lib/codex-sdk/dist/src/turnOptions.js.map +1 -0
- package/lib/codex-sdk/dist/tests/abort.test.d.ts +2 -0
- package/lib/codex-sdk/dist/tests/abort.test.d.ts.map +1 -0
- package/lib/codex-sdk/dist/tests/abort.test.js +130 -0
- package/lib/codex-sdk/dist/tests/abort.test.js.map +1 -0
- package/lib/codex-sdk/dist/tests/codexExecSpy.d.ts +6 -0
- package/lib/codex-sdk/dist/tests/codexExecSpy.d.ts.map +1 -0
- package/lib/codex-sdk/dist/tests/codexExecSpy.js +28 -0
- package/lib/codex-sdk/dist/tests/codexExecSpy.js.map +1 -0
- package/lib/codex-sdk/dist/tests/responsesProxy.d.ts +56 -0
- package/lib/codex-sdk/dist/tests/responsesProxy.d.ts.map +1 -0
- package/lib/codex-sdk/dist/tests/responsesProxy.js +155 -0
- package/lib/codex-sdk/dist/tests/responsesProxy.js.map +1 -0
- package/lib/codex-sdk/dist/tests/run.test.d.ts +2 -0
- package/lib/codex-sdk/dist/tests/run.test.d.ts.map +1 -0
- package/lib/codex-sdk/dist/tests/run.test.js +524 -0
- package/lib/codex-sdk/dist/tests/run.test.js.map +1 -0
- package/lib/codex-sdk/dist/tests/runStreamed.test.d.ts +2 -0
- package/lib/codex-sdk/dist/tests/runStreamed.test.d.ts.map +1 -0
- package/lib/codex-sdk/dist/tests/runStreamed.test.js +153 -0
- package/lib/codex-sdk/dist/tests/runStreamed.test.js.map +1 -0
- package/lib/codex-sdk/samples/basic_streaming.ts +0 -0
- package/lib/codex-sdk/samples/structured_output.ts +0 -0
- package/lib/codex-sdk/samples/structured_output_zod.ts +0 -0
- package/lib/codex-sdk/src/codex.ts +38 -0
- package/lib/codex-sdk/src/codexOptions.ts +10 -0
- package/lib/codex-sdk/src/events.ts +80 -0
- package/lib/codex-sdk/src/exec.ts +336 -0
- package/lib/codex-sdk/src/index.ts +39 -0
- package/lib/codex-sdk/src/items.ts +127 -0
- package/lib/codex-sdk/src/outputSchemaFile.ts +40 -0
- package/lib/codex-sdk/src/thread.ts +155 -0
- package/lib/codex-sdk/src/threadOptions.ts +18 -0
- package/lib/codex-sdk/src/turnOptions.ts +6 -0
- package/lib/codex-sdk/tests/abort.test.ts +165 -0
- package/lib/codex-sdk/tests/codexExecSpy.ts +37 -0
- package/lib/codex-sdk/tests/responsesProxy.ts +225 -0
- package/lib/codex-sdk/tests/run.test.ts +687 -0
- package/lib/codex-sdk/tests/runStreamed.test.ts +211 -0
- package/package.json +15 -16
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// based on item types from codex-rs/exec/src/exec_events.rs
|
|
2
|
+
|
|
3
|
+
import type { ContentBlock as McpContentBlock } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
|
|
5
|
+
/** The status of a command execution. */
|
|
6
|
+
export type CommandExecutionStatus = "in_progress" | "completed" | "failed";
|
|
7
|
+
|
|
8
|
+
/** A command executed by the agent. */
|
|
9
|
+
export type CommandExecutionItem = {
|
|
10
|
+
id: string;
|
|
11
|
+
type: "command_execution";
|
|
12
|
+
/** The command line executed by the agent. */
|
|
13
|
+
command: string;
|
|
14
|
+
/** Aggregated stdout and stderr captured while the command was running. */
|
|
15
|
+
aggregated_output: string;
|
|
16
|
+
/** Set when the command exits; omitted while still running. */
|
|
17
|
+
exit_code?: number;
|
|
18
|
+
/** Current status of the command execution. */
|
|
19
|
+
status: CommandExecutionStatus;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Indicates the type of the file change. */
|
|
23
|
+
export type PatchChangeKind = "add" | "delete" | "update";
|
|
24
|
+
|
|
25
|
+
/** A set of file changes by the agent. */
|
|
26
|
+
export type FileUpdateChange = {
|
|
27
|
+
path: string;
|
|
28
|
+
kind: PatchChangeKind;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The status of a file change. */
|
|
32
|
+
export type PatchApplyStatus = "completed" | "failed";
|
|
33
|
+
|
|
34
|
+
/** A set of file changes by the agent. Emitted once the patch succeeds or fails. */
|
|
35
|
+
export type FileChangeItem = {
|
|
36
|
+
id: string;
|
|
37
|
+
type: "file_change";
|
|
38
|
+
/** Individual file changes that comprise the patch. */
|
|
39
|
+
changes: FileUpdateChange[];
|
|
40
|
+
/** Whether the patch ultimately succeeded or failed. */
|
|
41
|
+
status: PatchApplyStatus;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The status of an MCP tool call. */
|
|
45
|
+
export type McpToolCallStatus = "in_progress" | "completed" | "failed";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Represents a call to an MCP tool. The item starts when the invocation is dispatched
|
|
49
|
+
* and completes when the MCP server reports success or failure.
|
|
50
|
+
*/
|
|
51
|
+
export type McpToolCallItem = {
|
|
52
|
+
id: string;
|
|
53
|
+
type: "mcp_tool_call";
|
|
54
|
+
/** Name of the MCP server handling the request. */
|
|
55
|
+
server: string;
|
|
56
|
+
/** The tool invoked on the MCP server. */
|
|
57
|
+
tool: string;
|
|
58
|
+
/** Arguments forwarded to the tool invocation. */
|
|
59
|
+
arguments: unknown;
|
|
60
|
+
/** Result payload returned by the MCP server for successful calls. */
|
|
61
|
+
result?: {
|
|
62
|
+
content: McpContentBlock[];
|
|
63
|
+
structured_content: unknown;
|
|
64
|
+
};
|
|
65
|
+
/** Error message reported for failed calls. */
|
|
66
|
+
error?: {
|
|
67
|
+
message: string;
|
|
68
|
+
};
|
|
69
|
+
/** Current status of the tool invocation. */
|
|
70
|
+
status: McpToolCallStatus;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Response from the agent. Either natural-language text or JSON when structured output is requested. */
|
|
74
|
+
export type AgentMessageItem = {
|
|
75
|
+
id: string;
|
|
76
|
+
type: "agent_message";
|
|
77
|
+
/** Either natural-language text or JSON when structured output is requested. */
|
|
78
|
+
text: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Agent's reasoning summary. */
|
|
82
|
+
export type ReasoningItem = {
|
|
83
|
+
id: string;
|
|
84
|
+
type: "reasoning";
|
|
85
|
+
text: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Captures a web search request. Completes when results are returned to the agent. */
|
|
89
|
+
export type WebSearchItem = {
|
|
90
|
+
id: string;
|
|
91
|
+
type: "web_search";
|
|
92
|
+
query: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Describes a non-fatal error surfaced as an item. */
|
|
96
|
+
export type ErrorItem = {
|
|
97
|
+
id: string;
|
|
98
|
+
type: "error";
|
|
99
|
+
message: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** An item in the agent's to-do list. */
|
|
103
|
+
export type TodoItem = {
|
|
104
|
+
text: string;
|
|
105
|
+
completed: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Tracks the agent's running to-do list. Starts when the plan is issued, updates as steps change,
|
|
110
|
+
* and completes when the turn ends.
|
|
111
|
+
*/
|
|
112
|
+
export type TodoListItem = {
|
|
113
|
+
id: string;
|
|
114
|
+
type: "todo_list";
|
|
115
|
+
items: TodoItem[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Canonical union of thread items and their type-specific payloads. */
|
|
119
|
+
export type ThreadItem =
|
|
120
|
+
| AgentMessageItem
|
|
121
|
+
| ReasoningItem
|
|
122
|
+
| CommandExecutionItem
|
|
123
|
+
| FileChangeItem
|
|
124
|
+
| McpToolCallItem
|
|
125
|
+
| WebSearchItem
|
|
126
|
+
| TodoListItem
|
|
127
|
+
| ErrorItem;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export type OutputSchemaFile = {
|
|
6
|
+
schemaPath?: string;
|
|
7
|
+
cleanup: () => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export async function createOutputSchemaFile(schema: unknown): Promise<OutputSchemaFile> {
|
|
11
|
+
if (schema === undefined) {
|
|
12
|
+
return { cleanup: async () => {} };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!isJsonObject(schema)) {
|
|
16
|
+
throw new Error("outputSchema must be a plain JSON object");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const schemaDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-output-schema-"));
|
|
20
|
+
const schemaPath = path.join(schemaDir, "schema.json");
|
|
21
|
+
const cleanup = async () => {
|
|
22
|
+
try {
|
|
23
|
+
await fs.rm(schemaDir, { recursive: true, force: true });
|
|
24
|
+
} catch {
|
|
25
|
+
// suppress
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await fs.writeFile(schemaPath, JSON.stringify(schema), "utf8");
|
|
31
|
+
return { schemaPath, cleanup };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
await cleanup();
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { CodexOptions } from "./codexOptions";
|
|
2
|
+
import { ThreadEvent, ThreadError, Usage } from "./events";
|
|
3
|
+
import { CodexExec } from "./exec";
|
|
4
|
+
import { ThreadItem } from "./items";
|
|
5
|
+
import { ThreadOptions } from "./threadOptions";
|
|
6
|
+
import { TurnOptions } from "./turnOptions";
|
|
7
|
+
import { createOutputSchemaFile } from "./outputSchemaFile";
|
|
8
|
+
|
|
9
|
+
/** Completed turn. */
|
|
10
|
+
export type Turn = {
|
|
11
|
+
items: ThreadItem[];
|
|
12
|
+
finalResponse: string;
|
|
13
|
+
usage: Usage | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Alias for `Turn` to describe the result of `run()`. */
|
|
17
|
+
export type RunResult = Turn;
|
|
18
|
+
|
|
19
|
+
/** The result of the `runStreamed` method. */
|
|
20
|
+
export type StreamedTurn = {
|
|
21
|
+
events: AsyncGenerator<ThreadEvent>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Alias for `StreamedTurn` to describe the result of `runStreamed()`. */
|
|
25
|
+
export type RunStreamedResult = StreamedTurn;
|
|
26
|
+
|
|
27
|
+
/** An input to send to the agent. */
|
|
28
|
+
export type UserInput =
|
|
29
|
+
| {
|
|
30
|
+
type: "text";
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
type: "local_image";
|
|
35
|
+
path: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Input = string | UserInput[];
|
|
39
|
+
|
|
40
|
+
/** Respesent a thread of conversation with the agent. One thread can have multiple consecutive turns. */
|
|
41
|
+
export class Thread {
|
|
42
|
+
private _exec: CodexExec;
|
|
43
|
+
private _options: CodexOptions;
|
|
44
|
+
private _id: string | null;
|
|
45
|
+
private _threadOptions: ThreadOptions;
|
|
46
|
+
|
|
47
|
+
/** Returns the ID of the thread. Populated after the first turn starts. */
|
|
48
|
+
public get id(): string | null {
|
|
49
|
+
return this._id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* @internal */
|
|
53
|
+
constructor(
|
|
54
|
+
exec: CodexExec,
|
|
55
|
+
options: CodexOptions,
|
|
56
|
+
threadOptions: ThreadOptions,
|
|
57
|
+
id: string | null = null,
|
|
58
|
+
) {
|
|
59
|
+
this._exec = exec;
|
|
60
|
+
this._options = options;
|
|
61
|
+
this._id = id;
|
|
62
|
+
this._threadOptions = threadOptions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Provides the input to the agent and streams events as they are produced during the turn. */
|
|
66
|
+
async runStreamed(input: Input, turnOptions: TurnOptions = {}): Promise<StreamedTurn> {
|
|
67
|
+
return { events: this.runStreamedInternal(input, turnOptions) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async *runStreamedInternal(
|
|
71
|
+
input: Input,
|
|
72
|
+
turnOptions: TurnOptions = {},
|
|
73
|
+
): AsyncGenerator<ThreadEvent> {
|
|
74
|
+
const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema);
|
|
75
|
+
const options = this._threadOptions;
|
|
76
|
+
const { prompt, images } = normalizeInput(input);
|
|
77
|
+
const generator = this._exec.run({
|
|
78
|
+
input: prompt,
|
|
79
|
+
baseUrl: this._options.baseUrl,
|
|
80
|
+
apiKey: this._options.apiKey,
|
|
81
|
+
threadId: this._id,
|
|
82
|
+
images,
|
|
83
|
+
model: options?.model,
|
|
84
|
+
sandboxMode: options?.sandboxMode,
|
|
85
|
+
workingDirectory: options?.workingDirectory,
|
|
86
|
+
skipGitRepoCheck: options?.skipGitRepoCheck,
|
|
87
|
+
outputSchemaFile: schemaPath,
|
|
88
|
+
modelReasoningEffort: options?.modelReasoningEffort,
|
|
89
|
+
signal: turnOptions.signal,
|
|
90
|
+
networkAccessEnabled: options?.networkAccessEnabled,
|
|
91
|
+
webSearchEnabled: options?.webSearchEnabled,
|
|
92
|
+
approvalPolicy: options?.approvalPolicy,
|
|
93
|
+
additionalDirectories: options?.additionalDirectories,
|
|
94
|
+
configOverrides: options?.configOverrides,
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
for await (const item of generator) {
|
|
98
|
+
let parsed: ThreadEvent;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(item) as ThreadEvent;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new Error(`Failed to parse item: ${item}`, { cause: error });
|
|
103
|
+
}
|
|
104
|
+
if (parsed.type === "thread.started") {
|
|
105
|
+
this._id = parsed.thread_id;
|
|
106
|
+
}
|
|
107
|
+
yield parsed;
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
await cleanup();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Provides the input to the agent and returns the completed turn. */
|
|
115
|
+
async run(input: Input, turnOptions: TurnOptions = {}): Promise<Turn> {
|
|
116
|
+
const generator = this.runStreamedInternal(input, turnOptions);
|
|
117
|
+
const items: ThreadItem[] = [];
|
|
118
|
+
let finalResponse: string = "";
|
|
119
|
+
let usage: Usage | null = null;
|
|
120
|
+
let turnFailure: ThreadError | null = null;
|
|
121
|
+
for await (const event of generator) {
|
|
122
|
+
if (event.type === "item.completed") {
|
|
123
|
+
if (event.item.type === "agent_message") {
|
|
124
|
+
finalResponse = event.item.text;
|
|
125
|
+
}
|
|
126
|
+
items.push(event.item);
|
|
127
|
+
} else if (event.type === "turn.completed") {
|
|
128
|
+
usage = event.usage;
|
|
129
|
+
} else if (event.type === "turn.failed") {
|
|
130
|
+
turnFailure = event.error;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (turnFailure) {
|
|
135
|
+
throw new Error(turnFailure.message);
|
|
136
|
+
}
|
|
137
|
+
return { items, finalResponse, usage };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeInput(input: Input): { prompt: string; images: string[] } {
|
|
142
|
+
if (typeof input === "string") {
|
|
143
|
+
return { prompt: input, images: [] };
|
|
144
|
+
}
|
|
145
|
+
const promptParts: string[] = [];
|
|
146
|
+
const images: string[] = [];
|
|
147
|
+
for (const item of input) {
|
|
148
|
+
if (item.type === "text") {
|
|
149
|
+
promptParts.push(item.text);
|
|
150
|
+
} else if (item.type === "local_image") {
|
|
151
|
+
images.push(item.path);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { prompt: promptParts.join("\n\n"), images };
|
|
155
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ApprovalMode = "never" | "on-request" | "on-failure" | "untrusted";
|
|
2
|
+
|
|
3
|
+
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
4
|
+
|
|
5
|
+
export type ModelReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
6
|
+
|
|
7
|
+
export type ThreadOptions = {
|
|
8
|
+
model?: string;
|
|
9
|
+
sandboxMode?: SandboxMode;
|
|
10
|
+
workingDirectory?: string;
|
|
11
|
+
skipGitRepoCheck?: boolean;
|
|
12
|
+
modelReasoningEffort?: ModelReasoningEffort;
|
|
13
|
+
networkAccessEnabled?: boolean;
|
|
14
|
+
webSearchEnabled?: boolean;
|
|
15
|
+
approvalPolicy?: ApprovalMode;
|
|
16
|
+
additionalDirectories?: string[];
|
|
17
|
+
configOverrides?: Record<string, unknown>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "@jest/globals";
|
|
4
|
+
|
|
5
|
+
import { Codex } from "../src/codex";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
assistantMessage,
|
|
9
|
+
responseCompleted,
|
|
10
|
+
responseStarted,
|
|
11
|
+
shell_call as shellCall,
|
|
12
|
+
sse,
|
|
13
|
+
SseResponseBody,
|
|
14
|
+
startResponsesTestProxy,
|
|
15
|
+
} from "./responsesProxy";
|
|
16
|
+
|
|
17
|
+
const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex");
|
|
18
|
+
|
|
19
|
+
function* infiniteShellCall(): Generator<SseResponseBody> {
|
|
20
|
+
while (true) {
|
|
21
|
+
yield sse(responseStarted(), shellCall(), responseCompleted());
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("AbortSignal support", () => {
|
|
26
|
+
it("aborts run() when signal is aborted", async () => {
|
|
27
|
+
const { url, close } = await startResponsesTestProxy({
|
|
28
|
+
statusCode: 200,
|
|
29
|
+
responseBodies: infiniteShellCall(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
|
|
34
|
+
const thread = client.startThread();
|
|
35
|
+
|
|
36
|
+
// Create an abort controller and abort it immediately
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
controller.abort("Test abort");
|
|
39
|
+
|
|
40
|
+
// The operation should fail because the signal is already aborted
|
|
41
|
+
await expect(thread.run("Hello, world!", { signal: controller.signal })).rejects.toThrow();
|
|
42
|
+
} finally {
|
|
43
|
+
await close();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("aborts runStreamed() when signal is aborted", async () => {
|
|
48
|
+
const { url, close } = await startResponsesTestProxy({
|
|
49
|
+
statusCode: 200,
|
|
50
|
+
responseBodies: infiniteShellCall(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
|
|
55
|
+
const thread = client.startThread();
|
|
56
|
+
|
|
57
|
+
// Create an abort controller and abort it immediately
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
controller.abort("Test abort");
|
|
60
|
+
|
|
61
|
+
const { events } = await thread.runStreamed("Hello, world!", { signal: controller.signal });
|
|
62
|
+
|
|
63
|
+
// Attempting to iterate should fail
|
|
64
|
+
let iterationStarted = false;
|
|
65
|
+
try {
|
|
66
|
+
for await (const event of events) {
|
|
67
|
+
iterationStarted = true;
|
|
68
|
+
// Should not get here
|
|
69
|
+
expect(event).toBeUndefined();
|
|
70
|
+
}
|
|
71
|
+
// If we get here, the test should fail
|
|
72
|
+
throw new Error(
|
|
73
|
+
"Expected iteration to throw due to aborted signal, but it completed successfully",
|
|
74
|
+
);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// We expect an error to be thrown
|
|
77
|
+
expect(iterationStarted).toBe(false); // Should fail before any iteration
|
|
78
|
+
expect(error).toBeDefined();
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
await close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("aborts run() when signal is aborted during execution", async () => {
|
|
86
|
+
const { url, close } = await startResponsesTestProxy({
|
|
87
|
+
statusCode: 200,
|
|
88
|
+
responseBodies: infiniteShellCall(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
|
|
93
|
+
const thread = client.startThread();
|
|
94
|
+
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
|
|
97
|
+
// Start the operation and abort it immediately after
|
|
98
|
+
const runPromise = thread.run("Hello, world!", { signal: controller.signal });
|
|
99
|
+
|
|
100
|
+
// Abort after a tiny delay to simulate aborting during execution
|
|
101
|
+
setTimeout(() => controller.abort("Aborted during execution"), 10);
|
|
102
|
+
|
|
103
|
+
// The operation should fail
|
|
104
|
+
await expect(runPromise).rejects.toThrow();
|
|
105
|
+
} finally {
|
|
106
|
+
await close();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("aborts runStreamed() when signal is aborted during iteration", async () => {
|
|
111
|
+
const { url, close } = await startResponsesTestProxy({
|
|
112
|
+
statusCode: 200,
|
|
113
|
+
responseBodies: infiniteShellCall(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
|
|
118
|
+
const thread = client.startThread();
|
|
119
|
+
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
|
|
122
|
+
const { events } = await thread.runStreamed("Hello, world!", { signal: controller.signal });
|
|
123
|
+
|
|
124
|
+
// Abort during iteration
|
|
125
|
+
let eventCount = 0;
|
|
126
|
+
await expect(
|
|
127
|
+
(async () => {
|
|
128
|
+
for await (const event of events) {
|
|
129
|
+
void event; // Consume the event
|
|
130
|
+
eventCount++;
|
|
131
|
+
// Abort after first event
|
|
132
|
+
if (eventCount === 5) {
|
|
133
|
+
controller.abort("Aborted during iteration");
|
|
134
|
+
}
|
|
135
|
+
// Continue iterating - should eventually throw
|
|
136
|
+
}
|
|
137
|
+
})(),
|
|
138
|
+
).rejects.toThrow();
|
|
139
|
+
} finally {
|
|
140
|
+
await close();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("completes normally when signal is not aborted", async () => {
|
|
145
|
+
const { url, close } = await startResponsesTestProxy({
|
|
146
|
+
statusCode: 200,
|
|
147
|
+
responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
|
|
152
|
+
const thread = client.startThread();
|
|
153
|
+
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
|
|
156
|
+
// Don't abort - should complete successfully
|
|
157
|
+
const result = await thread.run("Hello, world!", { signal: controller.signal });
|
|
158
|
+
|
|
159
|
+
expect(result.finalResponse).toBe("Hi!");
|
|
160
|
+
expect(result.items).toHaveLength(1);
|
|
161
|
+
} finally {
|
|
162
|
+
await close();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as child_process from "node:child_process";
|
|
2
|
+
|
|
3
|
+
jest.mock("node:child_process", () => {
|
|
4
|
+
const actual = jest.requireActual<typeof import("node:child_process")>("node:child_process");
|
|
5
|
+
return { ...actual, spawn: jest.fn(actual.spawn) };
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const actualChildProcess =
|
|
9
|
+
jest.requireActual<typeof import("node:child_process")>("node:child_process");
|
|
10
|
+
const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>;
|
|
11
|
+
|
|
12
|
+
export function codexExecSpy(): {
|
|
13
|
+
args: string[][];
|
|
14
|
+
envs: (Record<string, string> | undefined)[];
|
|
15
|
+
restore: () => void;
|
|
16
|
+
} {
|
|
17
|
+
const previousImplementation = spawnMock.getMockImplementation() ?? actualChildProcess.spawn;
|
|
18
|
+
const args: string[][] = [];
|
|
19
|
+
const envs: (Record<string, string> | undefined)[] = [];
|
|
20
|
+
|
|
21
|
+
spawnMock.mockImplementation(((...spawnArgs: Parameters<typeof child_process.spawn>) => {
|
|
22
|
+
const commandArgs = spawnArgs[1];
|
|
23
|
+
args.push(Array.isArray(commandArgs) ? [...commandArgs] : []);
|
|
24
|
+
const options = spawnArgs[2] as child_process.SpawnOptions | undefined;
|
|
25
|
+
envs.push(options?.env as Record<string, string> | undefined);
|
|
26
|
+
return previousImplementation(...spawnArgs);
|
|
27
|
+
}) as typeof actualChildProcess.spawn);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
args,
|
|
31
|
+
envs,
|
|
32
|
+
restore: () => {
|
|
33
|
+
spawnMock.mockClear();
|
|
34
|
+
spawnMock.mockImplementation(previousImplementation);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|