mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
chore: remove the TypeScript code from the repository (#2048)
This deletes the bulk of the `codex-cli` folder and eliminates the logic that builds the TypeScript code and bundles it into the release. Since this PR modifies `.github/workflows/rust-release.yml`, to test changes to the release process, I locally commented out all of the "is this commit on upstream `main`" checks in `scripts/create_github_release.sh` and ran: ``` ./codex-rs/scripts/create_github_release.sh 0.20.0-alpha.4 ``` Which kicked off: https://github.com/openai/codex/actions/runs/16842085113 And the release artifacts appear legit! https://github.com/openai/codex/releases/tag/rust-v0.20.0-alpha.4
This commit is contained in:
@@ -1 +0,0 @@
|
||||
hello a
|
||||
@@ -1 +0,0 @@
|
||||
hello b
|
||||
@@ -1,12 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`checkForUpdates() > renders a box when a newer version exists and no global installer 1`] = `
|
||||
"
|
||||
╭─────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Update available! 1.0.0 → 2.0.0. │
|
||||
│ To update, run bun add -g my-pkg to update. │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* tests/agent-azure-responses-endpoint.test.ts
|
||||
*
|
||||
* Verifies that AgentLoop calls the `/responses` endpoint when provider is set to Azure.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Fake stream that yields a completed response event
|
||||
class FakeStream {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: { id: "azure_resp", status: "completed", output: [] },
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
let lastCreateParams: any = null;
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeDefaultClient {
|
||||
public responses = {
|
||||
create: async (params: any) => {
|
||||
lastCreateParams = params;
|
||||
return new FakeStream();
|
||||
},
|
||||
};
|
||||
}
|
||||
class FakeAzureClient {
|
||||
public responses = {
|
||||
create: async (params: any) => {
|
||||
lastCreateParams = params;
|
||||
return new FakeStream();
|
||||
},
|
||||
};
|
||||
}
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeDefaultClient,
|
||||
AzureOpenAI: FakeAzureClient,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
// Stub approvals to bypass command approval logic
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }),
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
// Stub format-command to avoid formatting side effects
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
}));
|
||||
|
||||
// Stub internal logging to keep output clean
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop Azure provider responses endpoint", () => {
|
||||
beforeEach(() => {
|
||||
lastCreateParams = null;
|
||||
});
|
||||
|
||||
it("calls the /responses endpoint when provider is azure", async () => {
|
||||
const cfg: any = {
|
||||
model: "test-model",
|
||||
provider: "azure",
|
||||
instructions: "",
|
||||
disableResponseStorage: false,
|
||||
notify: false,
|
||||
};
|
||||
const loop = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: cfg.model,
|
||||
config: cfg,
|
||||
instructions: cfg.instructions,
|
||||
approvalPolicy: { mode: "suggest" } as any,
|
||||
onItem: () => {},
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
await loop.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(lastCreateParams).not.toBeNull();
|
||||
expect(lastCreateParams.model).toBe(cfg.model);
|
||||
expect(Array.isArray(lastCreateParams.input)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Fake stream that waits a bit before yielding the function_call so the test
|
||||
// can cancel first.
|
||||
class SlowFunctionCallStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "slow_call",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["echo", "hi"] }),
|
||||
},
|
||||
} as any;
|
||||
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_slow",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "slow_call",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["echo", "hi"] }),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("openai", () => {
|
||||
const bodies: Array<any> = [];
|
||||
let callCount = 0;
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async (body: any) => {
|
||||
bodies.push(body);
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return new SlowFunctionCallStream();
|
||||
}
|
||||
return new (class {
|
||||
public controller = { abort: vi.fn() };
|
||||
async *[Symbol.asyncIterator]() {}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
_test: { getBodies: () => bodies },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("cancel before first function_call", () => {
|
||||
it("clears previous_response_id if no call ids captured", async () => {
|
||||
const { _test } = (await import("openai")) as any;
|
||||
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
onItem: () => {},
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
});
|
||||
|
||||
// Start first run.
|
||||
agent.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "do" }],
|
||||
},
|
||||
] as any);
|
||||
|
||||
// Cancel quickly before any stream item.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
agent.cancel();
|
||||
|
||||
// Second run.
|
||||
await agent.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "new" }],
|
||||
},
|
||||
] as any);
|
||||
|
||||
const bodies = _test.getBodies();
|
||||
const last = bodies[bodies.length - 1];
|
||||
expect(last.previous_response_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Stream that emits a function_call so the agent records a `lastResponseId`.
|
||||
class StreamWithFunctionCall {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// First, deliver the function call.
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "call123",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["echo", "hi"] }),
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Then conclude the turn.
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_func_call", // lastResponseId that would normally be stored
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call123",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["echo", "hi"] }),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("openai", () => {
|
||||
const invocationBodies: Array<any> = [];
|
||||
let callNum = 0;
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async (body: any) => {
|
||||
invocationBodies.push(body);
|
||||
callNum += 1;
|
||||
// First call streams a function_call, second call returns empty stream.
|
||||
if (callNum === 1) {
|
||||
return new StreamWithFunctionCall();
|
||||
}
|
||||
// Subsequent calls: empty stream.
|
||||
return new (class {
|
||||
public controller = { abort: vi.fn() };
|
||||
async *[Symbol.asyncIterator]() {
|
||||
/* no events */
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
_test: {
|
||||
getBodies: () => invocationBodies,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stub helpers not relevant for this test.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Now import the agent.
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("cancel clears previous_response_id", () => {
|
||||
it("second run after cancel should NOT include previous_response_id", async () => {
|
||||
const { _test } = (await import("openai")) as any;
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: () => {},
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
});
|
||||
|
||||
// First run that triggers a function_call, but we will cancel *before* the
|
||||
// turn completes so the tool result is never returned.
|
||||
agent.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "do something" }],
|
||||
},
|
||||
] as any);
|
||||
// Give it a moment to receive the function_call.
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
// Cancel (simulate ESC ESC).
|
||||
agent.cancel();
|
||||
|
||||
// Second user input.
|
||||
await agent.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "new command" }],
|
||||
},
|
||||
] as any);
|
||||
|
||||
const bodies = _test.getBodies();
|
||||
expect(bodies.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The *last* invocation belongs to the second run (after cancellation).
|
||||
const found = bodies.some(
|
||||
(b: any) =>
|
||||
Array.isArray(b.input) &&
|
||||
b.input.some(
|
||||
(i: any) =>
|
||||
i.type === "function_call_output" && i.call_id === "call123",
|
||||
),
|
||||
);
|
||||
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
// This test reproduces the real‑world issue where the user cancels the current
|
||||
// task (Esc Esc) but the model’s response has already started to stream — the
|
||||
// partial answer still shows up in the UI.
|
||||
|
||||
// --- Mocks -----------------------------------------------------------------
|
||||
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Introduce a delay to simulate network latency and allow for cancel() to be called
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Mimic an assistant message containing the word "hello".
|
||||
// Our fix should prevent this from being emitted after cancel() is called
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
} as any;
|
||||
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("openai", () => {
|
||||
let callCount = 0;
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async () => {
|
||||
callCount += 1;
|
||||
// Only the *first* stream yields "hello" so that any later answer
|
||||
// clearly comes from the canceled run.
|
||||
return callCount === 1
|
||||
? new FakeStream()
|
||||
: new (class {
|
||||
public controller = { abort: vi.fn() };
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// empty stream
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
|
||||
});
|
||||
|
||||
// Stubs for external helpers referenced indirectly.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
// Stub the logger to avoid file‑system side effects during tests.
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
describe("Agent cancellation race", () => {
|
||||
// This test verifies our fix for the race condition where a cancelled message
|
||||
// could still appear after the user cancels a request.
|
||||
it("should not emit messages after cancel() is called", async () => {
|
||||
const items: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "any",
|
||||
instructions: "",
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
onItem: (i) => items.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const input = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "say hello" }],
|
||||
},
|
||||
];
|
||||
|
||||
agent.run(input as any);
|
||||
|
||||
// Cancel after the stream has started.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
agent.cancel();
|
||||
|
||||
// Immediately issue a new (empty) command to mimic the UI letting the user
|
||||
// type something else – this resets the agent state.
|
||||
agent.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "noop" }],
|
||||
},
|
||||
] as any);
|
||||
|
||||
// Give everything time to flush.
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
const assistantMsg = items.find((i) => i.role === "assistant");
|
||||
// Our fix should prevent the assistant message from being delivered after cancel
|
||||
// Now that we've fixed it, the test should pass
|
||||
expect(assistantMsg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
// Mock the OpenAI SDK used inside AgentLoop so we can control streaming events.
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Immediately yield a function_call item.
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "call1",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["node", "-e", "console.log('hi')"] }),
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Indicate turn completion with the same function_call.
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call1",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({
|
||||
cmd: ["node", "-e", "console.log('hi')"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async () => new FakeStream(),
|
||||
};
|
||||
}
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
|
||||
});
|
||||
|
||||
// Mock the approvals and formatCommand helpers referenced by handle‑exec‑command.
|
||||
vi.mock("../src/approvals.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () =>
|
||||
({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/format-command.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the logger to avoid file‑system side effects during tests.
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// After mocking dependencies we can import the modules under test.
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
import * as handleExec from "../src/utils/agent/handle-exec-command.js";
|
||||
|
||||
describe("Agent cancellation", () => {
|
||||
it("does not emit function_call_output after cancel", async () => {
|
||||
// Mock handleExecCommand to simulate a slow shell command that would write
|
||||
// "hello" if allowed to finish.
|
||||
vi.spyOn(handleExec, "handleExecCommand").mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return { outputText: "hello", metadata: {} } as any;
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (item) => {
|
||||
received.push(item);
|
||||
},
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "say hi" }],
|
||||
},
|
||||
];
|
||||
|
||||
// Start the agent loop but don't await it – we'll cancel while it's running.
|
||||
agent.run(userMsg as any);
|
||||
|
||||
// Give the agent a moment to start processing.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Cancel the task.
|
||||
agent.cancel();
|
||||
|
||||
// Wait a little longer to allow any pending promises to settle.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Ensure no function_call_output items were emitted after cancellation.
|
||||
const hasOutput = received.some((i) => i.type === "function_call_output");
|
||||
expect(hasOutput).toBe(false);
|
||||
});
|
||||
|
||||
it("still suppresses output when cancellation happens after a fast exec", async () => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Quick exec mock (returns immediately).
|
||||
vi.spyOn(handleExec, "handleExecCommand").mockResolvedValue({
|
||||
outputText: "hello-fast",
|
||||
metadata: {},
|
||||
} as any);
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "any",
|
||||
instructions: "",
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
onItem: (item) => received.push(item),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "say hi" }],
|
||||
},
|
||||
];
|
||||
|
||||
agent.run(userMsg as any);
|
||||
|
||||
// Wait a bit so the exec has certainly finished and output is ready.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
agent.cancel();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const hasOutput = received.some((i) => i.type === "function_call_output");
|
||||
expect(hasOutput).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// This regression test ensures that AgentLoop only surfaces each response item
|
||||
// once even when the same item appears multiple times in the OpenAI streaming
|
||||
// response (e.g. as an early `response.output_item.done` event *and* again in
|
||||
// the final `response.completed` payload).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fake OpenAI stream that emits the *same* message twice: first as an
|
||||
// incremental output event and then again in the turn completion payload.
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// 1) Early incremental item.
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "call-dedupe-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_text", text: "Hello!" }],
|
||||
},
|
||||
} as any;
|
||||
|
||||
// 2) Turn completion containing the *same* item again.
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp-dedupe-1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
id: "call-dedupe-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_text", text: "Hello!" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept the OpenAI SDK used inside AgentLoop so we can inject our fake
|
||||
// streaming implementation.
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async () => new FakeStream(),
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
|
||||
});
|
||||
|
||||
// Stub approvals / formatting helpers – not relevant here.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// After the dependency mocks we can import the module under test.
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop deduplicates output items", () => {
|
||||
it("invokes onItem exactly once for duplicate items with the same id", async () => {
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (item) => received.push(item),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hi" }],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.run(userMsg as any);
|
||||
|
||||
// Give the setTimeout(3ms) inside AgentLoop.stageItem a chance to fire.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
// Count how many times the duplicate item surfaced.
|
||||
const appearances = received.filter((i) => i.id === "call-dedupe-1").length;
|
||||
expect(appearances).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
// ---------------------------------------------------------------------------
|
||||
// This regression test ensures that the AgentLoop correctly copies the ID of a
|
||||
// function tool‑call (be it `call_id` from the /responses endpoint *or* `id`
|
||||
// from the /chat endpoint) into the subsequent `function_call_output` item. A
|
||||
// missing or mismatched ID leads to the dreaded
|
||||
// 400 | No tool output found for function call …
|
||||
// error from the OpenAI API.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fake OpenAI stream that immediately yields a *chat‑style* function_call item.
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
// Chat endpoint style (id + nested function descriptor)
|
||||
type: "function_call",
|
||||
id: "call_test_123",
|
||||
function: {
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["echo", "hi"] }),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_test_123",
|
||||
function: {
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["echo", "hi"] }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
// We intercept the OpenAI SDK so we can inspect the body of the second call –
|
||||
// the one that is expected to contain our `function_call_output` item.
|
||||
vi.mock("openai", () => {
|
||||
let invocation = 0;
|
||||
let capturedSecondBody: any;
|
||||
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async (body: any) => {
|
||||
invocation += 1;
|
||||
if (invocation === 1) {
|
||||
return new FakeStream();
|
||||
}
|
||||
if (invocation === 2) {
|
||||
capturedSecondBody = body;
|
||||
// empty stream
|
||||
return new (class {
|
||||
public controller = { abort: vi.fn() };
|
||||
async *[Symbol.asyncIterator]() {
|
||||
/* no items */
|
||||
}
|
||||
})();
|
||||
}
|
||||
throw new Error("Unexpected additional invocation in test");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
// Re‑export so the test can access the captured body.
|
||||
_test: {
|
||||
getCapturedSecondBody: () => capturedSecondBody,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stub approvals & command formatting – not relevant for this test.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
// Stub logger to keep the test output clean.
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Finally, import the module under test.
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("function_call_output includes original call ID", () => {
|
||||
it("copies id → call_id so the API accepts the tool result", async () => {
|
||||
const { _test } = (await import("openai")) as any;
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: () => {},
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "run" }],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.run(userMsg as any);
|
||||
|
||||
// Give the agent a tick to finish the second round‑trip.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const body = _test.getCapturedSecondBody();
|
||||
expect(body).toBeTruthy();
|
||||
|
||||
const outputItem = body.input?.find(
|
||||
(i: any) => i.type === "function_call_output",
|
||||
);
|
||||
expect(outputItem).toBeTruthy();
|
||||
expect(outputItem.call_id).toBe("call_test_123");
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility helpers & OpenAI mock (lightweight – focuses on network failures)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openAiState: { createSpy?: ReturnType<typeof vi.fn> } = {};
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: (...args: Array<any>) => openAiState.createSpy!(...args),
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
// Stub approvals / formatting helpers – unrelated to network handling.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
// Silence debug logs so test output stays clean.
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop – generic network/server errors", () => {
|
||||
it("emits friendly system message instead of throwing on ECONNRESET", async () => {
|
||||
const netErr: any = new Error("socket hang up");
|
||||
netErr.code = "ECONNRESET";
|
||||
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
throw netErr;
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "ping" }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(agent.run(userMsg as any)).resolves.not.toThrow();
|
||||
|
||||
// give flush timers a chance
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
typeof i.content?.[0]?.text === "string" &&
|
||||
i.content[0].text.includes("Network error"),
|
||||
);
|
||||
|
||||
expect(sysMsg).toBeTruthy();
|
||||
});
|
||||
|
||||
it("emits user friendly message on HTTP 500 from OpenAI", async () => {
|
||||
const serverErr: any = new Error("Internal Server Error");
|
||||
serverErr.status = 500;
|
||||
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
throw serverErr;
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "ping" }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(agent.run(userMsg as any)).resolves.not.toThrow();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
typeof i.content?.[0]?.text === "string" &&
|
||||
i.content[0].text.includes("error"),
|
||||
);
|
||||
|
||||
expect(sysMsg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
// Create a state holder for our mocks
|
||||
const openAiState = {
|
||||
createSpy: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock the OpenAI client
|
||||
vi.mock("openai", () => {
|
||||
return {
|
||||
default: class MockOpenAI {
|
||||
responses = {
|
||||
create: openAiState.createSpy,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Agent interrupt and continue", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("allows continuing after interruption", async () => {
|
||||
// Track received items
|
||||
const received: Array<any> = [];
|
||||
let loadingState = false;
|
||||
|
||||
// Create the agent
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "test-model",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
config: {
|
||||
model: "test-model",
|
||||
instructions: "",
|
||||
notify: false,
|
||||
},
|
||||
onItem: (item) => received.push(item),
|
||||
onLoading: (loading) => {
|
||||
loadingState = loading;
|
||||
},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
// First user message
|
||||
const firstMessage = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "first message" }],
|
||||
},
|
||||
];
|
||||
|
||||
// Setup the first mock response
|
||||
openAiState.createSpy.mockImplementation(() => {
|
||||
// Return a mock stream object
|
||||
return {
|
||||
controller: {
|
||||
abort: vi.fn(),
|
||||
},
|
||||
on: (event: string, callback: (...args: Array<any>) => void) => {
|
||||
if (event === "message") {
|
||||
// Schedule a message to be delivered
|
||||
setTimeout(() => {
|
||||
callback({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_text", text: "First response" }],
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
return { controller: { abort: vi.fn() } };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Start the first run
|
||||
const firstRunPromise = agent.run(firstMessage as any);
|
||||
|
||||
// Advance timers to allow the stream to start
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
|
||||
// Interrupt the agent
|
||||
agent.cancel();
|
||||
|
||||
// Verify loading state is reset
|
||||
expect(loadingState).toBe(false);
|
||||
|
||||
// Second user message
|
||||
const secondMessage = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "second message" }],
|
||||
},
|
||||
];
|
||||
|
||||
// Reset the mock to track the second call
|
||||
openAiState.createSpy.mockClear();
|
||||
|
||||
// Setup the second mock response
|
||||
openAiState.createSpy.mockImplementation(() => {
|
||||
// Return a mock stream object
|
||||
return {
|
||||
controller: {
|
||||
abort: vi.fn(),
|
||||
},
|
||||
on: (event: string, callback: (...args: Array<any>) => void) => {
|
||||
if (event === "message") {
|
||||
// Schedule a message to be delivered
|
||||
setTimeout(() => {
|
||||
callback({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_text", text: "Second response" }],
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
return { controller: { abort: vi.fn() } };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Start the second run
|
||||
const secondRunPromise = agent.run(secondMessage as any);
|
||||
|
||||
// Advance timers to allow the second stream to complete
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
// Ensure both promises resolve
|
||||
await Promise.all([firstRunPromise, secondRunPromise]);
|
||||
|
||||
// Verify the second API call was made
|
||||
expect(openAiState.createSpy).toHaveBeenCalled();
|
||||
|
||||
// Verify that the agent can process new input after cancellation
|
||||
expect(loadingState).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openAiState: { createSpy?: ReturnType<typeof vi.fn> } = {};
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: (...args: Array<any>) => openAiState.createSpy!(...args),
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop – invalid request / 4xx errors", () => {
|
||||
it("shows system message and resolves on invalid_request_error", async () => {
|
||||
const err: any = new Error("Invalid request: model not found");
|
||||
err.code = "invalid_request_error";
|
||||
err.status = 400;
|
||||
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(agent.run(userMsg as any)).resolves.not.toThrow();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
typeof i.content?.[0]?.text === "string" &&
|
||||
i.content[0].text.includes("OpenAI rejected"),
|
||||
);
|
||||
|
||||
expect(sysMsg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openAiState: { createSpy?: ReturnType<typeof vi.fn> } = {};
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: (...args: Array<any>) => openAiState.createSpy!(...args),
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop – max_tokens too large error", () => {
|
||||
it("shows context‑length system message and resolves", async () => {
|
||||
const err: any = new Error(
|
||||
"max_tokens is too large: 167888. This model supports at most 100000 completion tokens, whereas you provided 167888.",
|
||||
);
|
||||
err.type = "invalid_request_error";
|
||||
err.param = "max_tokens";
|
||||
err.status = 400;
|
||||
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(agent.run(userMsg as any)).resolves.not.toThrow();
|
||||
|
||||
// allow asynchronous onItem calls to flush
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
typeof i.content?.[0]?.text === "string" &&
|
||||
i.content[0].text.includes("exceeds the maximum context length"),
|
||||
);
|
||||
|
||||
expect(sysMsg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: fake OpenAI SDK with programmable behaviour per test case.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A minimal helper to build predetermined streams.
|
||||
function createStream(events: Array<any>, opts: { throwAfter?: Error } = {}) {
|
||||
return new (class {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const ev of events) {
|
||||
yield ev;
|
||||
}
|
||||
if (opts.throwAfter) {
|
||||
throw opts.throwAfter;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Holders so tests can access spies/state injected by the mock.
|
||||
const openAiState: {
|
||||
createSpy?: ReturnType<typeof vi.fn>;
|
||||
} = {};
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
// `createSpy` will be swapped out per test.
|
||||
create: (...args: Array<any>) => openAiState.createSpy!(...args),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
// Stub approvals / formatting helpers – not relevant here.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
// Silence debug logging from agent‑loop.
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop – network resilience", () => {
|
||||
it("retries once on APIConnectionTimeoutError and succeeds", async () => {
|
||||
// Arrange fake OpenAI: first call throws APIConnectionTimeoutError, second returns a short stream.
|
||||
const { APIConnectionTimeoutError } = await import("openai");
|
||||
|
||||
let call = 0;
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
throw new APIConnectionTimeoutError({ message: "timeout" });
|
||||
}
|
||||
// Second attempt – minimal assistant reply.
|
||||
return createStream([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "r1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hi" }],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.run(userMsg as any);
|
||||
|
||||
// Wait a tick for flush.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
const assistant = received.find((i) => i.role === "assistant");
|
||||
expect(assistant).toBeTruthy();
|
||||
expect(assistant.content?.[0]?.text).toBe("ok");
|
||||
});
|
||||
|
||||
it("shows system message when connection closes prematurely", async () => {
|
||||
const prematureError = new Error("Premature close");
|
||||
// @ts-ignore add code prop
|
||||
prematureError.code = "ERR_STREAM_PREMATURE_CLOSE";
|
||||
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
return createStream([], { throwAfter: prematureError });
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hi" }],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.run(userMsg as any);
|
||||
|
||||
// Wait a tick.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
i.content?.[0]?.text?.includes("Connection closed prematurely"),
|
||||
);
|
||||
expect(sysMsg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers & mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fake stream returned from the mocked OpenAI SDK. The AgentLoop only cares
|
||||
// that the stream is async‑iterable and eventually yields a `response.completed`
|
||||
// event so the turn can finish.
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "r1",
|
||||
status: "completed",
|
||||
output: [],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the parameters that AgentLoop sends to `openai.responses.create()` so
|
||||
// we can assert on the `instructions` value.
|
||||
let lastCreateParams: any = null;
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async (params: any) => {
|
||||
lastCreateParams = params;
|
||||
return new FakeStream();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
// The AgentLoop pulls these helpers in order to decide whether a command can
|
||||
// be auto‑approved. None of that matters for this test, so we stub the module
|
||||
// with minimal no-op implementations.
|
||||
vi.mock("../src/approvals.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () =>
|
||||
({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/format-command.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the file‑based logger to avoid side effects and keep the test output
|
||||
// clean.
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// After mocks are in place we can import the modules under test.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
import { loadConfig } from "../src/utils/config.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh temporary directory to act as an isolated git repo.
|
||||
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
|
||||
mkdirSync(join(projectDir, ".git")); // mark as project root
|
||||
|
||||
// Write a small project doc that we expect to be included in the prompt.
|
||||
writeFileSync(join(projectDir, "codex.md"), "# Test Project\nHello docs!\n");
|
||||
|
||||
lastCreateParams = null; // reset captured SDK params
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("AgentLoop", () => {
|
||||
it("passes codex.md contents through the instructions parameter", async () => {
|
||||
const config = loadConfig(undefined, undefined, { cwd: projectDir });
|
||||
|
||||
// Sanity‑check that loadConfig picked up the project doc. This is *not* the
|
||||
// main assertion – we just avoid a false‑positive if the fixture setup is
|
||||
// incorrect.
|
||||
expect(config.instructions).toContain("Hello docs!");
|
||||
|
||||
const agent = new AgentLoop({
|
||||
additionalWritableRoots: [],
|
||||
model: "o3", // arbitrary
|
||||
instructions: config.instructions,
|
||||
config,
|
||||
approvalPolicy: { mode: "suggest" } as any,
|
||||
onItem: () => {},
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
// Kick off a single run and wait for it to finish. The fake OpenAI client
|
||||
// will resolve immediately.
|
||||
await agent.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "ping" }],
|
||||
},
|
||||
]);
|
||||
|
||||
// Ensure the AgentLoop called the SDK and that the instructions we see at
|
||||
// that point still include the project doc. This validates the full path:
|
||||
// loadConfig → AgentLoop → addInstructionPrefix → OpenAI SDK.
|
||||
expect(lastCreateParams).not.toBeNull();
|
||||
expect(lastCreateParams.instructions).toContain("Hello docs!");
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Keep reference so test cases can programmatically change behaviour of the
|
||||
// fake OpenAI client.
|
||||
const openAiState: { createSpy?: ReturnType<typeof vi.fn> } = {};
|
||||
|
||||
/**
|
||||
* Mock the "openai" package so we can simulate rate‑limit errors without
|
||||
* making real network calls. The AgentLoop only relies on `responses.create`
|
||||
* so we expose a minimal stub.
|
||||
*/
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
// Will be replaced per‑test via `openAiState.createSpy`.
|
||||
create: (...args: Array<any>) => openAiState.createSpy!(...args),
|
||||
};
|
||||
}
|
||||
|
||||
// The real SDK exports this constructor – include it for typings even
|
||||
// though it is not used in this spec.
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
// Stub helpers that the agent indirectly imports so it does not attempt any
|
||||
// file‑system access or real approvals logic during the test.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
// Silence agent‑loop debug logging so test output stays clean.
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop – rate‑limit handling", () => {
|
||||
it("retries up to the maximum and then surfaces a system message", async () => {
|
||||
// Enable fake timers for this test only – we restore real timers at the end
|
||||
// so other tests are unaffected.
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
// Construct a dummy rate‑limit error that matches the implementation's
|
||||
// detection logic (`status === 429`).
|
||||
const rateLimitErr: any = new Error("Rate limit exceeded");
|
||||
rateLimitErr.status = 429;
|
||||
|
||||
// Always throw the rate‑limit error to force the loop to exhaust all
|
||||
// retries (5 attempts in total).
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
throw rateLimitErr;
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
},
|
||||
];
|
||||
|
||||
// Start the run but don't await yet so we can advance fake timers while it
|
||||
// is in progress.
|
||||
const runPromise = agent.run(userMsg as any);
|
||||
|
||||
// Should be done in at most 180 seconds.
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
|
||||
// Ensure the promise settles without throwing.
|
||||
await expect(runPromise).resolves.not.toThrow();
|
||||
|
||||
// Flush the 10 ms staging delay used when emitting items.
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
// The OpenAI client should have been called the maximum number of retry
|
||||
// attempts (8).
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(8);
|
||||
|
||||
// Finally, verify that the user sees a helpful system message.
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
typeof i.content?.[0]?.text === "string" &&
|
||||
i.content[0].text.includes("Rate limit reached"),
|
||||
);
|
||||
|
||||
expect(sysMsg).toBeTruthy();
|
||||
} finally {
|
||||
// Ensure global timer state is restored for subsequent tests.
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Utility: fake OpenAI SDK that can be instructed to fail with 5xx a set
|
||||
// number of times before succeeding.
|
||||
|
||||
function createStream(events: Array<any>) {
|
||||
return new (class {
|
||||
public controller = { abort: vi.fn() };
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const ev of events) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
const openAiState: { createSpy?: ReturnType<typeof vi.fn> } = {};
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: (...args: Array<any>) => openAiState.createSpy!(...args),
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
APIConnectionTimeoutError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop – automatic retry on 5xx errors", () => {
|
||||
it("retries up to 3 times then succeeds", async () => {
|
||||
// Fail twice with 500 then succeed.
|
||||
let call = 0;
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
call += 1;
|
||||
if (call <= 2) {
|
||||
const err: any = new Error("Internal Server Error");
|
||||
err.status = 500;
|
||||
throw err;
|
||||
}
|
||||
return createStream([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "r1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hi" }],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.run(userMsg as any);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
const assistant = received.find((i) => i.role === "assistant");
|
||||
expect(assistant?.content?.[0]?.text).toBe("ok");
|
||||
});
|
||||
|
||||
it("fails after a few attempts and surfaces system message", async () => {
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
const err: any = new Error("Internal Server Error");
|
||||
err.status = 502; // any 5xx
|
||||
throw err;
|
||||
});
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (i) => received.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(agent.run(userMsg as any)).resolves.not.toThrow();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(8);
|
||||
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
typeof i.content?.[0]?.text === "string" &&
|
||||
i.content[0].text.includes("Network error"),
|
||||
);
|
||||
|
||||
expect(sysMsg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// --- OpenAI stream mock ----------------------------------------------------
|
||||
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Immediately ask for a shell function call so we can test that the
|
||||
// subsequent function_call_output never gets surfaced after terminate().
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "call‑terminate‑1",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["sleep", "5"] }),
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Turn completion echoing the same function call.
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp‑terminate‑1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call‑terminate‑1",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ cmd: ["sleep", "5"] }),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async () => new FakeStream(),
|
||||
};
|
||||
}
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
|
||||
});
|
||||
|
||||
// --- Helpers referenced by handle‑exec‑command -----------------------------
|
||||
|
||||
vi.mock("../src/approvals.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () =>
|
||||
({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/format-command.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub logger to avoid filesystem side‑effects
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// After dependency mocks we can import the modules under test.
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
import * as handleExec from "../src/utils/agent/handle-exec-command.js";
|
||||
|
||||
describe("Agent terminate (hard cancel)", () => {
|
||||
it("suppresses function_call_output and stops processing once terminate() is invoked", async () => {
|
||||
// Simulate a long‑running exec that would normally resolve with output.
|
||||
vi.spyOn(handleExec, "handleExecCommand").mockImplementation(
|
||||
async (
|
||||
_args,
|
||||
_config,
|
||||
_policy,
|
||||
_additionalWritableRoots,
|
||||
_getConf,
|
||||
abortSignal,
|
||||
) => {
|
||||
// Wait until the abort signal is fired or 2s (whichever comes first).
|
||||
await new Promise<void>((resolve) => {
|
||||
if (abortSignal?.aborted) {
|
||||
return resolve();
|
||||
}
|
||||
const timer = setTimeout(resolve, 2000);
|
||||
abortSignal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return { outputText: "should‑not‑happen", metadata: {} } as any;
|
||||
},
|
||||
);
|
||||
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (item) => received.push(item),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "run long cmd" }],
|
||||
},
|
||||
];
|
||||
|
||||
// Start agent loop but don't wait for completion.
|
||||
agent.run(userMsg as any);
|
||||
|
||||
// Give it a brief moment to start and process the function_call.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
agent.terminate();
|
||||
|
||||
// Allow promises to settle.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const hasOutput = received.some((i) => i.type === "function_call_output");
|
||||
expect(hasOutput).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects further run() calls after terminate()", async () => {
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: () => {},
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
agent.terminate();
|
||||
|
||||
const dummyMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "noop" }],
|
||||
},
|
||||
];
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
// We expect this to fail fast – either by throwing synchronously or by
|
||||
// returning a rejected promise.
|
||||
await agent.run(dummyMsg as any);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression test for the "thinking time" counter. Today the implementation
|
||||
// keeps a *single* start‑time across many requests which means that every
|
||||
// subsequent command will show an ever‑increasing number such as
|
||||
// "thinking for 4409s", "thinking for 4424s", … even though the individual
|
||||
// turn only took a couple of milliseconds. Each request should start its own
|
||||
// independent timer.
|
||||
//
|
||||
// We mark the spec with `.fails()` so that the overall suite remains green
|
||||
// until the underlying bug is fixed. When the implementation is corrected the
|
||||
// expectations below will turn green – Vitest will then error and remind us to
|
||||
// remove the `.fails` flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// --- OpenAI mock -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fake stream that yields a single `response.completed` after a configurable
|
||||
* delay. This allows us to simulate different thinking times for successive
|
||||
* requests while using Vitest's fake timers.
|
||||
*/
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
private delay: number;
|
||||
|
||||
constructor(delay: number) {
|
||||
this.delay = delay; // milliseconds
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
if (this.delay > 0) {
|
||||
// Wait the configured delay – fake timers will fast‑forward.
|
||||
await new Promise((r) => setTimeout(r, this.delay));
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: `resp-${Date.now()}`,
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake OpenAI client that returns a slower stream for the *first* call and a
|
||||
* faster one for the second so we can verify that per‑task timers reset while
|
||||
* the global counter accumulates.
|
||||
*/
|
||||
vi.mock("openai", () => {
|
||||
let callCount = 0;
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async () => {
|
||||
callCount += 1;
|
||||
return new FakeStream(callCount === 1 ? 10_000 : 500); // 10s vs 0.5s
|
||||
},
|
||||
};
|
||||
}
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
|
||||
});
|
||||
|
||||
// Stub helpers referenced indirectly so we do not pull in real FS/network
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (c: Array<string>) => c.join(" "),
|
||||
}));
|
||||
|
||||
// Suppress file‑system logging in tests.
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
describe("thinking time counter", () => {
|
||||
// Use fake timers for *all* tests in this suite
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Re‐use this array to collect all onItem callbacks
|
||||
let items: Array<any>;
|
||||
|
||||
// Helper that runs two agent turns (10s + 0.5s) and populates `items`
|
||||
async function runScenario() {
|
||||
items = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
config: {} as any,
|
||||
model: "any",
|
||||
instructions: "",
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (i) => items.push(i),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "do it" }],
|
||||
} as any;
|
||||
|
||||
// 1️⃣ First request – simulated 10s thinking time
|
||||
agent.run([userMsg]);
|
||||
await vi.advanceTimersByTimeAsync(11_000); // 10s + flush margin
|
||||
|
||||
// 2️⃣ Second request – simulated 0.5s thinking time
|
||||
agent.run([userMsg]);
|
||||
await vi.advanceTimersByTimeAsync(1_000); // 0.5s + flush margin
|
||||
}
|
||||
|
||||
// TODO: this is disabled
|
||||
it.fails("reports correct per-task thinking time per command", async () => {
|
||||
await runScenario();
|
||||
|
||||
const perTaskMsgs = items.filter(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
i.content?.[0]?.text?.startsWith("🤔 Thinking time:"),
|
||||
);
|
||||
|
||||
expect(perTaskMsgs.length).toBe(2);
|
||||
|
||||
const perTaskDurations = perTaskMsgs.map((m) => {
|
||||
const match = m.content[0].text.match(/Thinking time: (\d+) s/);
|
||||
return match ? parseInt(match[1]!, 10) : NaN;
|
||||
});
|
||||
|
||||
// First run ~10s, second run ~0.5s
|
||||
expect(perTaskDurations[0]).toBeGreaterThanOrEqual(9);
|
||||
expect(perTaskDurations[1]).toBeLessThan(3);
|
||||
});
|
||||
|
||||
// TODO: this is disabled
|
||||
it.fails("reports correct global thinking time accumulation", async () => {
|
||||
await runScenario();
|
||||
|
||||
const globalMsgs = items.filter(
|
||||
(i) =>
|
||||
i.role === "system" &&
|
||||
i.content?.[0]?.text?.startsWith("⏱ Total thinking time:"),
|
||||
);
|
||||
|
||||
expect(globalMsgs.length).toBe(2);
|
||||
|
||||
const globalDurations = globalMsgs.map((m) => {
|
||||
const match = m.content[0].text.match(/Total thinking time: (\d+) s/);
|
||||
return match ? parseInt(match[1]!, 10) : NaN;
|
||||
});
|
||||
|
||||
// Total after second run should exceed total after first
|
||||
expect(globalDurations[1]! as number).toBeGreaterThan(globalDurations[0]!);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// We import the module *lazily* inside each test so that we can control the
|
||||
// OPENAI_API_KEY env var independently per test case. Node's module cache
|
||||
// would otherwise capture the value present during the first import.
|
||||
|
||||
const ORIGINAL_ENV_KEY = process.env["OPENAI_API_KEY"];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENV_KEY !== undefined) {
|
||||
process.env["OPENAI_API_KEY"] = ORIGINAL_ENV_KEY;
|
||||
} else {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
}
|
||||
});
|
||||
|
||||
describe("config.setApiKey", () => {
|
||||
it("overrides the exported OPENAI_API_KEY at runtime", async () => {
|
||||
const { setApiKey, OPENAI_API_KEY } = await import(
|
||||
"../src/utils/config.js"
|
||||
);
|
||||
|
||||
expect(OPENAI_API_KEY).toBe("");
|
||||
|
||||
setApiKey("my‑key");
|
||||
|
||||
const { OPENAI_API_KEY: liveRef } = await import("../src/utils/config.js");
|
||||
|
||||
expect(liveRef).toBe("my‑key");
|
||||
});
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
ActionType,
|
||||
apply_commit,
|
||||
assemble_changes,
|
||||
DiffError,
|
||||
identify_files_added,
|
||||
identify_files_needed,
|
||||
load_files,
|
||||
patch_to_commit,
|
||||
process_patch,
|
||||
text_to_patch,
|
||||
} from "../src/utils/agent/apply-patch.js";
|
||||
import { test, expect } from "vitest";
|
||||
|
||||
function createInMemoryFS(initialFiles: Record<string, string>) {
|
||||
const files: Record<string, string> = { ...initialFiles };
|
||||
const writes: Record<string, string> = {};
|
||||
const removals: Array<string> = [];
|
||||
|
||||
const openFn = (p: string): string => {
|
||||
const file = files[p];
|
||||
if (typeof file === "string") {
|
||||
return file;
|
||||
} else {
|
||||
throw new Error(`File not found: ${p}`);
|
||||
}
|
||||
};
|
||||
|
||||
const writeFn = (p: string, content: string): void => {
|
||||
files[p] = content;
|
||||
writes[p] = content;
|
||||
};
|
||||
|
||||
const removeFn = (p: string): void => {
|
||||
delete files[p];
|
||||
removals.push(p);
|
||||
};
|
||||
|
||||
return { openFn, writeFn, removeFn, writes, removals, files };
|
||||
}
|
||||
|
||||
test("process_patch - update file", () => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: a.txt
|
||||
@@
|
||||
-hello
|
||||
+hello world
|
||||
*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({ "a.txt": "hello" });
|
||||
|
||||
const result = process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(result).toBe("Done!");
|
||||
expect(fs.writes).toEqual({ "a.txt": "hello world" });
|
||||
expect(fs.removals).toEqual([]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unicode canonicalisation tests – hyphen / dash / quote look-alikes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("process_patch tolerates hyphen/dash variants", () => {
|
||||
// The file contains EN DASH (\u2013) and NO-BREAK HYPHEN (\u2011)
|
||||
const original =
|
||||
"first\nimport foo # local import \u2013 avoids top\u2011level dep\nlast";
|
||||
|
||||
const patch = `*** Begin Patch\n*** Update File: uni.txt\n@@\n-import foo # local import - avoids top-level dep\n+import foo # HANDLED\n*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({ "uni.txt": original });
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(fs.files["uni.txt"]!.includes("HANDLED")).toBe(true);
|
||||
});
|
||||
|
||||
test.skip("process_patch tolerates smart quotes", () => {
|
||||
const original = "console.log(\u201Chello\u201D);"; // “hello” with smart quotes
|
||||
|
||||
const patch = `*** Begin Patch\n*** Update File: quotes.js\n@@\n-console.log(\\"hello\\");\n+console.log(\\"HELLO\\");\n*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({ "quotes.js": original });
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(fs.files["quotes.js"]).toBe('console.log("HELLO");');
|
||||
});
|
||||
|
||||
test("process_patch - add file", () => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Add File: b.txt
|
||||
+new content
|
||||
*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({});
|
||||
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(fs.writes).toEqual({ "b.txt": "new content" });
|
||||
expect(fs.removals).toEqual([]);
|
||||
});
|
||||
|
||||
test("process_patch - delete file", () => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Delete File: c.txt
|
||||
*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({ "c.txt": "to be removed" });
|
||||
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(fs.writes).toEqual({});
|
||||
expect(fs.removals).toEqual(["c.txt"]);
|
||||
});
|
||||
|
||||
test("identify_files_needed & identify_files_added", () => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: a.txt
|
||||
*** Delete File: b.txt
|
||||
*** Add File: c.txt
|
||||
*** End Patch`;
|
||||
|
||||
expect(identify_files_needed(patch).sort()).toEqual(
|
||||
["a.txt", "b.txt"].sort(),
|
||||
);
|
||||
expect(identify_files_added(patch)).toEqual(["c.txt"]);
|
||||
});
|
||||
|
||||
test("process_patch - update file with multiple chunks", () => {
|
||||
const original = "line1\nline2\nline3\nline4";
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: multi.txt
|
||||
@@
|
||||
line1
|
||||
-line2
|
||||
+line2 updated
|
||||
line3
|
||||
+inserted line
|
||||
line4
|
||||
*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({ "multi.txt": original });
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
const expected = "line1\nline2 updated\nline3\ninserted line\nline4";
|
||||
expect(fs.writes).toEqual({ "multi.txt": expected });
|
||||
expect(fs.removals).toEqual([]);
|
||||
});
|
||||
|
||||
test("process_patch - move file (rename)", () => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: old.txt
|
||||
*** Move to: new.txt
|
||||
@@
|
||||
-old
|
||||
+new
|
||||
*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({ "old.txt": "old" });
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(fs.writes).toEqual({ "new.txt": "new" });
|
||||
expect(fs.removals).toEqual(["old.txt"]);
|
||||
});
|
||||
|
||||
test("process_patch - combined add, update, delete", () => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Add File: added.txt
|
||||
+added contents
|
||||
*** Update File: upd.txt
|
||||
@@
|
||||
-old value
|
||||
+new value
|
||||
*** Delete File: del.txt
|
||||
*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({
|
||||
"upd.txt": "old value",
|
||||
"del.txt": "delete me",
|
||||
});
|
||||
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(fs.writes).toEqual({
|
||||
"added.txt": "added contents",
|
||||
"upd.txt": "new value",
|
||||
});
|
||||
expect(fs.removals).toEqual(["del.txt"]);
|
||||
});
|
||||
|
||||
test("process_patch - readme edit", () => {
|
||||
const original = `
|
||||
#### Fix an issue
|
||||
|
||||
\`\`\`sh
|
||||
# First, copy an error
|
||||
# Then, start codex with interactive mode
|
||||
codex
|
||||
|
||||
# Or you can pass in via command line argument
|
||||
codex "Fix this issue: $(pbpaste)"
|
||||
|
||||
# Or even as a task (it should use your current repo and branch)
|
||||
codex -t "Fix this issue: $(pbpaste)"
|
||||
\`\`\`
|
||||
`;
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: README.md
|
||||
@@
|
||||
codex -t "Fix this issue: $(pbpaste)"
|
||||
\`\`\`
|
||||
+
|
||||
+hello
|
||||
*** End Patch`;
|
||||
const expected = `
|
||||
#### Fix an issue
|
||||
|
||||
\`\`\`sh
|
||||
# First, copy an error
|
||||
# Then, start codex with interactive mode
|
||||
codex
|
||||
|
||||
# Or you can pass in via command line argument
|
||||
codex "Fix this issue: $(pbpaste)"
|
||||
|
||||
# Or even as a task (it should use your current repo and branch)
|
||||
codex -t "Fix this issue: $(pbpaste)"
|
||||
\`\`\`
|
||||
|
||||
hello
|
||||
`;
|
||||
|
||||
const fs = createInMemoryFS({ "README.md": original });
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
|
||||
expect(fs.writes).toEqual({ "README.md": expected });
|
||||
});
|
||||
|
||||
test("process_patch - invalid patch throws DiffError", () => {
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: missing.txt
|
||||
@@
|
||||
+something
|
||||
*** End Patch`;
|
||||
|
||||
const fs = createInMemoryFS({});
|
||||
|
||||
expect(() =>
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn),
|
||||
).toThrow(DiffError);
|
||||
});
|
||||
|
||||
test("process_patch - tolerates omitted space for keep line", () => {
|
||||
const original = "line1\nline2\nline3";
|
||||
const patch = `*** Begin Patch\n*** Update File: foo.txt\n@@\n line1\n-line2\n+some new line2\nline3\n*** End Patch`;
|
||||
const fs = createInMemoryFS({ "foo.txt": original });
|
||||
process_patch(patch, fs.openFn, fs.writeFn, fs.removeFn);
|
||||
expect(fs.files["foo.txt"]).toBe("line1\nsome new line2\nline3");
|
||||
});
|
||||
|
||||
test("assemble_changes correctly detects add, update and delete", () => {
|
||||
const orig = {
|
||||
"a.txt": "old",
|
||||
"b.txt": "keep",
|
||||
"c.txt": "remove",
|
||||
};
|
||||
const updated = {
|
||||
"a.txt": "new", // update
|
||||
"b.txt": "keep", // unchanged – should be ignored
|
||||
"c.txt": undefined as unknown as string, // delete
|
||||
"d.txt": "created", // add
|
||||
};
|
||||
|
||||
const commit = assemble_changes(orig, updated).changes;
|
||||
|
||||
expect(commit["a.txt"]).toEqual({
|
||||
type: ActionType.UPDATE,
|
||||
old_content: "old",
|
||||
new_content: "new",
|
||||
});
|
||||
expect(commit["c.txt"]).toEqual({
|
||||
type: ActionType.DELETE,
|
||||
old_content: "remove",
|
||||
});
|
||||
expect(commit["d.txt"]).toEqual({
|
||||
type: ActionType.ADD,
|
||||
new_content: "created",
|
||||
});
|
||||
|
||||
// unchanged files should not appear in commit
|
||||
expect(commit).not.toHaveProperty("b.txt");
|
||||
});
|
||||
|
||||
test("text_to_patch + patch_to_commit handle update and add", () => {
|
||||
const originalFiles = {
|
||||
"a.txt": "old line",
|
||||
};
|
||||
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: a.txt
|
||||
@@
|
||||
-old line
|
||||
+new line
|
||||
*** Add File: b.txt
|
||||
+content new
|
||||
*** End Patch`;
|
||||
|
||||
const [parsedPatch] = text_to_patch(patch, originalFiles);
|
||||
const commit = patch_to_commit(parsedPatch, originalFiles).changes;
|
||||
|
||||
expect(commit["a.txt"]).toEqual({
|
||||
type: ActionType.UPDATE,
|
||||
old_content: "old line",
|
||||
new_content: "new line",
|
||||
});
|
||||
expect(commit["b.txt"]).toEqual({
|
||||
type: ActionType.ADD,
|
||||
new_content: "content new",
|
||||
});
|
||||
});
|
||||
|
||||
test("load_files throws DiffError when file is missing", () => {
|
||||
const { openFn } = createInMemoryFS({ "exists.txt": "hi" });
|
||||
// intentionally include a missing file in the list
|
||||
expect(() => load_files(["exists.txt", "missing.txt"], openFn)).toThrow(
|
||||
DiffError,
|
||||
);
|
||||
});
|
||||
|
||||
test("apply_commit correctly performs move / rename operations", () => {
|
||||
const commit = {
|
||||
changes: {
|
||||
"old.txt": {
|
||||
type: ActionType.UPDATE,
|
||||
old_content: "old",
|
||||
new_content: "new",
|
||||
move_path: "new.txt",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { writeFn, removeFn, writes, removals } = createInMemoryFS({});
|
||||
|
||||
apply_commit(commit, writeFn, removeFn);
|
||||
|
||||
expect(writes).toEqual({ "new.txt": "new" });
|
||||
expect(removals).toEqual(["old.txt"]);
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import type { SafetyAssessment } from "../src/approvals";
|
||||
|
||||
import { canAutoApprove } from "../src/approvals";
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("canAutoApprove()", () => {
|
||||
const env = {
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
HOME: "/home/user",
|
||||
};
|
||||
|
||||
const writeablePaths: Array<string> = [];
|
||||
const check = (command: ReadonlyArray<string>): SafetyAssessment =>
|
||||
canAutoApprove(
|
||||
command,
|
||||
/* workdir */ undefined,
|
||||
"suggest",
|
||||
writeablePaths,
|
||||
env,
|
||||
);
|
||||
|
||||
test("simple safe commands", () => {
|
||||
expect(check(["ls"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["cat", "file.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "View file contents",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["nl", "-ba", "README.md"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "View file with line numbers",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["pwd"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Print working directory",
|
||||
group: "Navigating",
|
||||
runInSandbox: false,
|
||||
});
|
||||
|
||||
// Ripgrep safe invocation.
|
||||
expect(check(["rg", "TODO"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Ripgrep search",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("simple safe commands within a `bash -lc` call", () => {
|
||||
expect(check(["bash", "-lc", "ls"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["bash", "-lc", "ls $HOME"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["bash", "-lc", "git show ab9811cb90"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Git show",
|
||||
group: "Using git",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("ripgrep unsafe flags", () => {
|
||||
// Flags that do not take arguments
|
||||
expect(check(["rg", "--search-zip", "TODO"])).toEqual({ type: "ask-user" });
|
||||
expect(check(["rg", "-z", "TODO"])).toEqual({ type: "ask-user" });
|
||||
|
||||
// Flags that take arguments (provided separately)
|
||||
expect(check(["rg", "--pre", "cat", "TODO"])).toEqual({ type: "ask-user" });
|
||||
expect(check(["rg", "--hostname-bin", "hostname", "TODO"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
|
||||
// Flags that take arguments in = form
|
||||
expect(check(["rg", "--pre=cat", "TODO"])).toEqual({ type: "ask-user" });
|
||||
expect(check(["rg", "--hostname-bin=hostname", "TODO"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
});
|
||||
|
||||
test("bash -lc commands with unsafe redirects", () => {
|
||||
expect(check(["bash", "-lc", "echo hello > file.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
// In theory, we could make our checker more sophisticated to auto-approve
|
||||
// This previously required approval, but now that we consider safe
|
||||
// operators like "&&" the entire expression can be auto‑approved.
|
||||
expect(check(["bash", "-lc", "ls && pwd"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "List directory",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("true command is considered safe", () => {
|
||||
expect(check(["true"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "No-op (true)",
|
||||
group: "Utility",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("commands that should require approval", () => {
|
||||
// Should this be on the auto-approved list?
|
||||
expect(check(["printenv"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["git", "commit"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["pytest"])).toEqual({ type: "ask-user" });
|
||||
|
||||
expect(check(["cargo", "build"])).toEqual({ type: "ask-user" });
|
||||
});
|
||||
|
||||
test("find", () => {
|
||||
expect(check(["find", ".", "-name", "file.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Find files or directories",
|
||||
group: "Searching",
|
||||
runInSandbox: false,
|
||||
});
|
||||
|
||||
// Options that can execute arbitrary commands.
|
||||
expect(
|
||||
check(["find", ".", "-name", "file.txt", "-exec", "rm", "{}", ";"]),
|
||||
).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
expect(
|
||||
check(["find", ".", "-name", "*.py", "-execdir", "python3", "{}", ";"]),
|
||||
).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
expect(
|
||||
check(["find", ".", "-name", "file.txt", "-ok", "rm", "{}", ";"]),
|
||||
).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
expect(
|
||||
check(["find", ".", "-name", "*.py", "-okdir", "python3", "{}", ";"]),
|
||||
).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
|
||||
// Option that deletes matching files.
|
||||
expect(check(["find", ".", "-delete", "-name", "file.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
|
||||
// Options that write pathnames to a file.
|
||||
expect(check(["find", ".", "-fls", "/etc/passwd"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
expect(check(["find", ".", "-fprint", "/etc/passwd"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
expect(check(["find", ".", "-fprint0", "/etc/passwd"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
expect(
|
||||
check(["find", ".", "-fprintf", "/root/suid.txt", "%#m %u %p\n"]),
|
||||
).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
});
|
||||
|
||||
test("sed", () => {
|
||||
// `sed` used to read lines from a file.
|
||||
expect(check(["sed", "-n", "1,200p", "filename.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Sed print subset",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
// Bad quoting! The model is doing the wrong thing here, so this should not
|
||||
// be auto-approved.
|
||||
expect(check(["sed", "-n", "'1,200p'", "filename.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
// Extra arg: here we are extra conservative, we do not auto-approve.
|
||||
expect(check(["sed", "-n", "1,200p", "file1.txt", "file2.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
|
||||
// `sed` used to read lines from a file with a shell command.
|
||||
expect(check(["bash", "-lc", "sed -n '1,200p' filename.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Sed print subset",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
|
||||
// Pipe the output of `nl` to `sed`.
|
||||
expect(
|
||||
check(["bash", "-lc", "nl -ba README.md | sed -n '1,200p'"]),
|
||||
).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "View file with line numbers",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { AppConfig } from "src/utils/config.js";
|
||||
|
||||
// Import the low‑level exec implementation so we can verify that AbortSignal
|
||||
// correctly terminates a spawned process. We bypass the higher‑level wrappers
|
||||
// to keep the test focused and fast.
|
||||
|
||||
describe("exec cancellation", () => {
|
||||
it("kills the child process when the abort signal is triggered", async () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Spawn a node process that would normally run for 5 seconds before
|
||||
// printing anything. We should abort long before that happens.
|
||||
const cmd = ["node", "-e", "setTimeout(() => console.log('late'), 5000);"];
|
||||
const config: AppConfig = {
|
||||
model: "test-model",
|
||||
instructions: "test-instructions",
|
||||
};
|
||||
const start = Date.now();
|
||||
|
||||
const promise = rawExec(cmd, {}, config, abortController.signal);
|
||||
|
||||
// Abort almost immediately.
|
||||
abortController.abort();
|
||||
|
||||
const result = await promise;
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
// The process should have been terminated rapidly (well under the 5s the
|
||||
// child intended to run) – give it a generous 2s budget.
|
||||
expect(durationMs).toBeLessThan(2000);
|
||||
|
||||
// Exit code should indicate abnormal termination (anything but zero)
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
|
||||
// The child never got a chance to print the word "late".
|
||||
expect(result.stdout).not.toContain("late");
|
||||
});
|
||||
|
||||
it("allows the process to finish when not aborted", async () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const config: AppConfig = {
|
||||
model: "test-model",
|
||||
instructions: "test-instructions",
|
||||
};
|
||||
|
||||
const cmd = ["node", "-e", "console.log('finished')"];
|
||||
|
||||
const result = await rawExec(cmd, {}, config, abortController.signal);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("finished");
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import type { UpdateOptions } from "../src/utils/check-updates";
|
||||
import { getLatestVersion } from "fast-npm-meta";
|
||||
import { getUserAgent } from "package-manager-detector";
|
||||
import {
|
||||
checkForUpdates,
|
||||
renderUpdateCommand,
|
||||
} from "../src/utils/check-updates";
|
||||
import { detectInstallerByPath } from "../src/utils/package-manager-detector";
|
||||
import { CLI_VERSION } from "../src/version";
|
||||
|
||||
// In-memory FS mock
|
||||
let memfs: Record<string, string> = {};
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
readFile: async (path: string) => {
|
||||
if (!(path in memfs)) {
|
||||
const err: any = new Error(
|
||||
`ENOENT: no such file or directory, open '${path}'`,
|
||||
);
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return memfs[path];
|
||||
},
|
||||
writeFile: async (path: string, data: string) => {
|
||||
memfs[path] = data;
|
||||
},
|
||||
rm: async (path: string) => {
|
||||
delete memfs[path];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock package name & CLI version
|
||||
const MOCK_PKG = "my-pkg";
|
||||
vi.mock("../src/version", () => ({ CLI_VERSION: "1.0.0" }));
|
||||
vi.mock("../package.json", () => ({ name: MOCK_PKG }));
|
||||
vi.mock("../src/utils/package-manager-detector", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
detectInstallerByPath: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock external services
|
||||
vi.mock("fast-npm-meta", () => ({ getLatestVersion: vi.fn() }));
|
||||
vi.mock("package-manager-detector", () => ({ getUserAgent: vi.fn() }));
|
||||
|
||||
describe("renderUpdateCommand()", () => {
|
||||
it.each([
|
||||
[{ manager: "npm", packageName: MOCK_PKG }, `npm install -g ${MOCK_PKG}`],
|
||||
[{ manager: "pnpm", packageName: MOCK_PKG }, `pnpm add -g ${MOCK_PKG}`],
|
||||
[{ manager: "bun", packageName: MOCK_PKG }, `bun add -g ${MOCK_PKG}`],
|
||||
[{ manager: "yarn", packageName: MOCK_PKG }, `yarn global add ${MOCK_PKG}`],
|
||||
[
|
||||
{ manager: "deno", packageName: MOCK_PKG },
|
||||
`deno install -g npm:${MOCK_PKG}`,
|
||||
],
|
||||
])("%s → command", async (options, cmd) => {
|
||||
expect(renderUpdateCommand(options as UpdateOptions)).toBe(cmd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForUpdates()", () => {
|
||||
// Use a stable directory under the OS temp
|
||||
const TMP = join(os.tmpdir(), "update-test-memfs");
|
||||
const STATE_PATH = join(TMP, "update-check.json");
|
||||
|
||||
beforeEach(async () => {
|
||||
memfs = {};
|
||||
// Mock CONFIG_DIR to our TMP
|
||||
vi.doMock("../src/utils/config", () => ({ CONFIG_DIR: TMP }));
|
||||
|
||||
// Freeze time so the 24h logic is deterministic
|
||||
vi.useFakeTimers().setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses global installer when detected, ignoring local agent", async () => {
|
||||
// seed old timestamp
|
||||
const old = new Date("2000-01-01T00:00:00Z").toUTCString();
|
||||
memfs[STATE_PATH] = JSON.stringify({ lastUpdateCheck: old });
|
||||
|
||||
// simulate registry says update available
|
||||
vi.mocked(getLatestVersion).mockResolvedValue({ version: "2.0.0" } as any);
|
||||
// local agent would be npm, but global detection wins
|
||||
vi.mocked(getUserAgent).mockReturnValue("npm");
|
||||
vi.mocked(detectInstallerByPath).mockReturnValue(Promise.resolve("pnpm"));
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await checkForUpdates();
|
||||
|
||||
// should render using `pnpm` (global) rather than `npm`
|
||||
expect(logSpy).toHaveBeenCalledOnce();
|
||||
const output = logSpy.mock.calls.at(0)?.at(0);
|
||||
expect(output).toContain("pnpm add -g"); // global branch used
|
||||
// state updated
|
||||
const newState = JSON.parse(memfs[STATE_PATH]!);
|
||||
expect(newState.lastUpdateCheck).toBe(new Date().toUTCString());
|
||||
});
|
||||
|
||||
it("skips when lastUpdateCheck is still fresh (<frequency)", async () => {
|
||||
// seed a timestamp 12h ago
|
||||
const recent = new Date(Date.now() - 1000 * 60 * 60 * 12).toUTCString();
|
||||
memfs[STATE_PATH] = JSON.stringify({ lastUpdateCheck: recent });
|
||||
|
||||
const versionSpy = vi.mocked(getLatestVersion);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await checkForUpdates();
|
||||
|
||||
expect(versionSpy).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not print when up-to-date", async () => {
|
||||
vi.mocked(getLatestVersion).mockResolvedValue({
|
||||
version: CLI_VERSION,
|
||||
} as any);
|
||||
vi.mocked(getUserAgent).mockReturnValue("npm");
|
||||
vi.mocked(detectInstallerByPath).mockResolvedValue(undefined);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await checkForUpdates();
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
// but state still written
|
||||
const state = JSON.parse(memfs[STATE_PATH]!);
|
||||
expect(state.lastUpdateCheck).toBe(new Date().toUTCString());
|
||||
});
|
||||
|
||||
it("does not print when no manager detected at all", async () => {
|
||||
vi.mocked(getLatestVersion).mockResolvedValue({ version: "2.0.0" } as any);
|
||||
vi.mocked(detectInstallerByPath).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserAgent).mockReturnValue(null);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await checkForUpdates();
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
// state still written
|
||||
const state = JSON.parse(memfs[STATE_PATH]!);
|
||||
expect(state.lastUpdateCheck).toBe(new Date().toUTCString());
|
||||
});
|
||||
|
||||
it("renders a box when a newer version exists and no global installer", async () => {
|
||||
// old timestamp
|
||||
const old = new Date("2000-01-01T00:00:00Z").toUTCString();
|
||||
memfs[STATE_PATH] = JSON.stringify({ lastUpdateCheck: old });
|
||||
|
||||
vi.mocked(getLatestVersion).mockResolvedValue({ version: "2.0.0" } as any);
|
||||
vi.mocked(detectInstallerByPath).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserAgent).mockReturnValue("bun");
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await checkForUpdates();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledOnce();
|
||||
const output = logSpy.mock.calls[0]![0] as string;
|
||||
expect(output).toContain("bun add -g");
|
||||
expect(output).to.matchSnapshot();
|
||||
// state updated
|
||||
const state = JSON.parse(memfs[STATE_PATH]!);
|
||||
expect(state.lastUpdateCheck).toBe(new Date().toUTCString());
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import React from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||
import * as TermUtils from "../src/utils/terminal.js";
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
describe("/clear command", () => {
|
||||
it("invokes clearTerminal and resets context in TerminalChatInput", async () => {
|
||||
const clearSpy = vi
|
||||
.spyOn(TermUtils, "clearTerminal")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const setItems = vi.fn();
|
||||
|
||||
// Minimal stub of a ResponseItem – cast to bypass exhaustive type checks in this test context
|
||||
const existingItems = [
|
||||
{
|
||||
id: "dummy-1",
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: "Old item" }],
|
||||
},
|
||||
] as Array<any>;
|
||||
|
||||
const props: ComponentProps<typeof TerminalChatInput> = {
|
||||
isNew: false,
|
||||
loading: false,
|
||||
submitInput: () => {},
|
||||
confirmationPrompt: null,
|
||||
explanation: undefined,
|
||||
submitConfirmation: () => {},
|
||||
setLastResponseId: () => {},
|
||||
setItems,
|
||||
contextLeftPercent: 100,
|
||||
openOverlay: () => {},
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openDiffOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
items: existingItems,
|
||||
};
|
||||
|
||||
const { stdin, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...props} />,
|
||||
);
|
||||
|
||||
await flush();
|
||||
|
||||
await type(stdin, "/clear", flush);
|
||||
await type(stdin, "\r", flush); // press Enter
|
||||
|
||||
// Allow any asynchronous state updates to propagate
|
||||
await flush();
|
||||
|
||||
expect(clearSpy).toHaveBeenCalledTimes(2);
|
||||
expect(setItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
const stateUpdater = setItems.mock.calls[0]![0];
|
||||
expect(typeof stateUpdater).toBe("function");
|
||||
const newItems = stateUpdater(existingItems);
|
||||
expect(Array.isArray(newItems)).toBe(true);
|
||||
expect(newItems).toHaveLength(2);
|
||||
expect(newItems.at(-1)).toMatchObject({
|
||||
role: "system",
|
||||
type: "message",
|
||||
content: [{ type: "input_text", text: "Terminal cleared" }],
|
||||
});
|
||||
|
||||
cleanup();
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearTerminal", () => {
|
||||
it("writes escape sequence to stdout", () => {
|
||||
const originalQuiet = process.env["CODEX_QUIET_MODE"];
|
||||
delete process.env["CODEX_QUIET_MODE"];
|
||||
|
||||
process.env["CODEX_QUIET_MODE"] = "0";
|
||||
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
TermUtils.clearTerminal();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith("\x1b[3J\x1b[H\x1b[2J");
|
||||
|
||||
writeSpy.mockRestore();
|
||||
|
||||
if (originalQuiet !== undefined) {
|
||||
process.env["CODEX_QUIET_MODE"] = originalQuiet;
|
||||
} else {
|
||||
delete process.env["CODEX_QUIET_MODE"];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,363 +0,0 @@
|
||||
import type * as fsType from "fs";
|
||||
|
||||
import {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
DEFAULT_SHELL_MAX_BYTES,
|
||||
DEFAULT_SHELL_MAX_LINES,
|
||||
} from "../src/utils/config.js";
|
||||
import { AutoApprovalMode } from "../src/utils/auto-approval-mode.js";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { providers as defaultProviders } from "../src/utils/providers";
|
||||
|
||||
// In‑memory FS store
|
||||
let memfs: Record<string, string> = {};
|
||||
|
||||
// Mock out the parts of "fs" that our config module uses:
|
||||
vi.mock("fs", async () => {
|
||||
// now `real` is the actual fs module
|
||||
const real = (await vi.importActual("fs")) as typeof fsType;
|
||||
return {
|
||||
...real,
|
||||
existsSync: (path: string) => memfs[path] !== undefined,
|
||||
readFileSync: (path: string) => {
|
||||
if (memfs[path] === undefined) {
|
||||
throw new Error("ENOENT");
|
||||
}
|
||||
return memfs[path];
|
||||
},
|
||||
writeFileSync: (path: string, data: string) => {
|
||||
memfs[path] = data;
|
||||
},
|
||||
mkdirSync: () => {
|
||||
// no-op in in‑memory store
|
||||
},
|
||||
rmSync: (path: string) => {
|
||||
// recursively delete any key under this prefix
|
||||
const prefix = path.endsWith("/") ? path : path + "/";
|
||||
for (const key of Object.keys(memfs)) {
|
||||
if (key === path || key.startsWith(prefix)) {
|
||||
delete memfs[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let testDir: string;
|
||||
let testConfigPath: string;
|
||||
let testInstructionsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
memfs = {}; // reset in‑memory store
|
||||
testDir = tmpdir(); // use the OS temp dir as our "cwd"
|
||||
testConfigPath = join(testDir, "config.json");
|
||||
testInstructionsPath = join(testDir, "instructions.md");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
memfs = {};
|
||||
});
|
||||
|
||||
test("loads default config if files don't exist", () => {
|
||||
const config = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
// Keep the test focused on just checking that default model and instructions are loaded
|
||||
// so we need to make sure we check just these properties
|
||||
expect(config.model).toBe("codex-mini-latest");
|
||||
expect(config.instructions).toBe("");
|
||||
});
|
||||
|
||||
test("saves and loads config correctly", () => {
|
||||
const testConfig = {
|
||||
model: "test-model",
|
||||
instructions: "test instructions",
|
||||
notify: false,
|
||||
};
|
||||
saveConfig(testConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Our in‑memory fs should now contain those keys:
|
||||
expect(memfs[testConfigPath]).toContain(`"model": "test-model"`);
|
||||
expect(memfs[testInstructionsPath]).toBe("test instructions");
|
||||
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
// Check just the specified properties that were saved
|
||||
expect(loadedConfig.model).toBe(testConfig.model);
|
||||
expect(loadedConfig.instructions).toBe(testConfig.instructions);
|
||||
});
|
||||
|
||||
test("loads user instructions + project doc when codex.md is present", () => {
|
||||
// 1) seed memfs: a config JSON, an instructions.md, and a codex.md in the cwd
|
||||
const userInstr = "here are user instructions";
|
||||
const projectDoc = "# Project Title\n\nSome project‑specific doc";
|
||||
// first, make config so loadConfig will see storedConfig
|
||||
memfs[testConfigPath] = JSON.stringify({ model: "mymodel" }, null, 2);
|
||||
// then user instructions:
|
||||
memfs[testInstructionsPath] = userInstr;
|
||||
// and now our fake codex.md in the cwd:
|
||||
const codexPath = join(testDir, "codex.md");
|
||||
memfs[codexPath] = projectDoc;
|
||||
|
||||
// 2) loadConfig without disabling project‑doc, but with cwd=testDir
|
||||
const cfg = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
cwd: testDir,
|
||||
});
|
||||
|
||||
// 3) assert we got both pieces concatenated
|
||||
expect(cfg.model).toBe("mymodel");
|
||||
expect(cfg.instructions).toBe(
|
||||
userInstr + "\n\n--- project-doc ---\n\n" + projectDoc,
|
||||
);
|
||||
});
|
||||
|
||||
test("loads and saves approvalMode correctly", () => {
|
||||
// Setup config with approvalMode
|
||||
memfs[testConfigPath] = JSON.stringify(
|
||||
{
|
||||
model: "mymodel",
|
||||
approvalMode: AutoApprovalMode.AUTO_EDIT,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
memfs[testInstructionsPath] = "test instructions";
|
||||
|
||||
// Load config and verify approvalMode
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check approvalMode was loaded correctly
|
||||
expect(loadedConfig.approvalMode).toBe(AutoApprovalMode.AUTO_EDIT);
|
||||
|
||||
// Modify approvalMode and save
|
||||
const updatedConfig = {
|
||||
...loadedConfig,
|
||||
approvalMode: AutoApprovalMode.FULL_AUTO,
|
||||
};
|
||||
|
||||
saveConfig(updatedConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Verify saved config contains updated approvalMode
|
||||
expect(memfs[testConfigPath]).toContain(
|
||||
`"approvalMode": "${AutoApprovalMode.FULL_AUTO}"`,
|
||||
);
|
||||
|
||||
// Load again and verify updated value
|
||||
const reloadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
expect(reloadedConfig.approvalMode).toBe(AutoApprovalMode.FULL_AUTO);
|
||||
});
|
||||
|
||||
test("loads and saves providers correctly", () => {
|
||||
// Setup custom providers configuration
|
||||
const customProviders = {
|
||||
openai: {
|
||||
name: "Custom OpenAI",
|
||||
baseURL: "https://custom-api.openai.com/v1",
|
||||
envKey: "CUSTOM_OPENAI_API_KEY",
|
||||
},
|
||||
anthropic: {
|
||||
name: "Anthropic",
|
||||
baseURL: "https://api.anthropic.com",
|
||||
envKey: "ANTHROPIC_API_KEY",
|
||||
},
|
||||
};
|
||||
|
||||
// Create config with providers
|
||||
const testConfig = {
|
||||
model: "test-model",
|
||||
provider: "anthropic",
|
||||
providers: customProviders,
|
||||
instructions: "test instructions",
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Save the config
|
||||
saveConfig(testConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Verify saved config contains providers
|
||||
expect(memfs[testConfigPath]).toContain(`"providers"`);
|
||||
expect(memfs[testConfigPath]).toContain(`"Custom OpenAI"`);
|
||||
expect(memfs[testConfigPath]).toContain(`"Anthropic"`);
|
||||
expect(memfs[testConfigPath]).toContain(`"provider": "anthropic"`);
|
||||
|
||||
// Load config and verify providers were loaded correctly
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check providers were loaded correctly
|
||||
expect(loadedConfig.provider).toBe("anthropic");
|
||||
expect(loadedConfig.providers).toEqual({
|
||||
...defaultProviders,
|
||||
...customProviders,
|
||||
});
|
||||
|
||||
// Test merging with built-in providers
|
||||
// Create a config with only one custom provider
|
||||
const partialProviders = {
|
||||
customProvider: {
|
||||
name: "Custom Provider",
|
||||
baseURL: "https://custom-api.example.com",
|
||||
envKey: "CUSTOM_API_KEY",
|
||||
},
|
||||
};
|
||||
|
||||
const partialConfig = {
|
||||
model: "test-model",
|
||||
provider: "customProvider",
|
||||
providers: partialProviders,
|
||||
instructions: "test instructions",
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Save the partial config
|
||||
saveConfig(partialConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Load config and verify providers were merged with built-in providers
|
||||
const mergedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check providers is defined
|
||||
expect(mergedConfig.providers).toBeDefined();
|
||||
|
||||
// Use bracket notation to access properties
|
||||
if (mergedConfig.providers) {
|
||||
expect(mergedConfig.providers["customProvider"]).toBeDefined();
|
||||
expect(mergedConfig.providers["customProvider"]).toEqual(
|
||||
partialProviders.customProvider,
|
||||
);
|
||||
// Built-in providers should still be there (like openai)
|
||||
expect(mergedConfig.providers["openai"]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("saves and loads instructions with project doc separator correctly", () => {
|
||||
const userInstructions = "user specific instructions";
|
||||
const projectDoc = "project specific documentation";
|
||||
const combinedInstructions = `${userInstructions}\n\n--- project-doc ---\n\n${projectDoc}`;
|
||||
|
||||
const testConfig = {
|
||||
model: "test-model",
|
||||
instructions: combinedInstructions,
|
||||
notify: false,
|
||||
};
|
||||
|
||||
saveConfig(testConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
expect(memfs[testInstructionsPath]).toBe(userInstructions);
|
||||
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
expect(loadedConfig.instructions).toBe(userInstructions);
|
||||
});
|
||||
|
||||
test("handles empty user instructions when saving with project doc separator", () => {
|
||||
const projectDoc = "project specific documentation";
|
||||
const combinedInstructions = `\n\n--- project-doc ---\n\n${projectDoc}`;
|
||||
|
||||
const testConfig = {
|
||||
model: "test-model",
|
||||
instructions: combinedInstructions,
|
||||
notify: false,
|
||||
};
|
||||
|
||||
saveConfig(testConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
expect(memfs[testInstructionsPath]).toBe("");
|
||||
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
expect(loadedConfig.instructions).toBe("");
|
||||
});
|
||||
|
||||
test("loads default shell config when not specified", () => {
|
||||
// Setup config without shell settings
|
||||
memfs[testConfigPath] = JSON.stringify(
|
||||
{
|
||||
model: "mymodel",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
memfs[testInstructionsPath] = "test instructions";
|
||||
|
||||
// Load config and verify default shell settings
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check shell settings were loaded with defaults
|
||||
expect(loadedConfig.tools).toBeDefined();
|
||||
expect(loadedConfig.tools?.shell).toBeDefined();
|
||||
expect(loadedConfig.tools?.shell?.maxBytes).toBe(DEFAULT_SHELL_MAX_BYTES);
|
||||
expect(loadedConfig.tools?.shell?.maxLines).toBe(DEFAULT_SHELL_MAX_LINES);
|
||||
});
|
||||
|
||||
test("loads and saves custom shell config", () => {
|
||||
// Setup config with custom shell settings
|
||||
const customMaxBytes = 12_410;
|
||||
const customMaxLines = 500;
|
||||
|
||||
memfs[testConfigPath] = JSON.stringify(
|
||||
{
|
||||
model: "mymodel",
|
||||
tools: {
|
||||
shell: {
|
||||
maxBytes: customMaxBytes,
|
||||
maxLines: customMaxLines,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
memfs[testInstructionsPath] = "test instructions";
|
||||
|
||||
// Load config and verify custom shell settings
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check shell settings were loaded correctly
|
||||
expect(loadedConfig.tools?.shell?.maxBytes).toBe(customMaxBytes);
|
||||
expect(loadedConfig.tools?.shell?.maxLines).toBe(customMaxLines);
|
||||
|
||||
// Modify shell settings and save
|
||||
const updatedMaxBytes = 20_000;
|
||||
const updatedMaxLines = 1_000;
|
||||
|
||||
const updatedConfig = {
|
||||
...loadedConfig,
|
||||
tools: {
|
||||
shell: {
|
||||
maxBytes: updatedMaxBytes,
|
||||
maxLines: updatedMaxLines,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
saveConfig(updatedConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Verify saved config contains updated shell settings
|
||||
expect(memfs[testConfigPath]).toContain(`"maxBytes": ${updatedMaxBytes}`);
|
||||
expect(memfs[testConfigPath]).toContain(`"maxLines": ${updatedMaxLines}`);
|
||||
|
||||
// Load again and verify updated values
|
||||
const reloadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
expect(reloadedConfig.tools?.shell?.maxBytes).toBe(updatedMaxBytes);
|
||||
expect(reloadedConfig.tools?.shell?.maxLines).toBe(updatedMaxLines);
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
loadConfig,
|
||||
DEFAULT_REASONING_EFFORT,
|
||||
saveConfig,
|
||||
} from "../src/utils/config";
|
||||
import type { ReasoningEffort } from "openai/resources.mjs";
|
||||
import * as fs from "fs";
|
||||
|
||||
// Mock the fs module
|
||||
vi.mock("fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock path.dirname
|
||||
vi.mock("path", async () => {
|
||||
const actual = await vi.importActual("path");
|
||||
return {
|
||||
...actual,
|
||||
dirname: vi.fn().mockReturnValue("/mock/dir"),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Reasoning Effort Configuration", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should have "high" as the default reasoning effort', () => {
|
||||
expect(DEFAULT_REASONING_EFFORT).toBe("high");
|
||||
});
|
||||
|
||||
it("should use default reasoning effort when not specified in config", () => {
|
||||
// Mock fs.existsSync to return true for config file
|
||||
vi.mocked(fs.existsSync).mockImplementation(() => true);
|
||||
|
||||
// Mock fs.readFileSync to return a JSON with no reasoningEffort
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() =>
|
||||
JSON.stringify({ model: "test-model" }),
|
||||
);
|
||||
|
||||
const config = loadConfig("/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Config should not have reasoningEffort explicitly set
|
||||
expect(config.reasoningEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should load reasoningEffort from config file", () => {
|
||||
// Mock fs.existsSync to return true for config file
|
||||
vi.mocked(fs.existsSync).mockImplementation(() => true);
|
||||
|
||||
// Mock fs.readFileSync to return a JSON with reasoningEffort
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() =>
|
||||
JSON.stringify({
|
||||
model: "test-model",
|
||||
reasoningEffort: "low" as ReasoningEffort,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = loadConfig("/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Config should have the reasoningEffort from the file
|
||||
expect(config.reasoningEffort).toBe("low");
|
||||
});
|
||||
|
||||
it("should support all valid reasoning effort values", () => {
|
||||
// Valid values for ReasoningEffort
|
||||
const validEfforts: Array<ReasoningEffort> = ["low", "medium", "high"];
|
||||
|
||||
for (const effort of validEfforts) {
|
||||
// Mock fs.existsSync to return true for config file
|
||||
vi.mocked(fs.existsSync).mockImplementation(() => true);
|
||||
|
||||
// Mock fs.readFileSync to return a JSON with reasoningEffort
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() =>
|
||||
JSON.stringify({
|
||||
model: "test-model",
|
||||
reasoningEffort: effort,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = loadConfig("/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Config should have the correct reasoningEffort
|
||||
expect(config.reasoningEffort).toBe(effort);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve reasoningEffort when saving configuration", () => {
|
||||
// Setup
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// Create config with reasoningEffort
|
||||
const configToSave = {
|
||||
model: "test-model",
|
||||
instructions: "",
|
||||
reasoningEffort: "medium" as ReasoningEffort,
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Act
|
||||
saveConfig(configToSave, "/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Assert
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
"/mock/config.json",
|
||||
expect.stringContaining('"model"'),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Note: Current implementation of saveConfig doesn't save reasoningEffort,
|
||||
// this test would need to be updated if that functionality is added
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { PassThrough } from "stream";
|
||||
import { once } from "events";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createTruncatingCollector } from "../src/utils/agent/sandbox/create-truncating-collector.js";
|
||||
|
||||
describe("createTruncatingCollector", () => {
|
||||
it("collects data under limits without truncation", async () => {
|
||||
const stream = new PassThrough();
|
||||
const collector = createTruncatingCollector(stream, 100, 10);
|
||||
const data = "line1\nline2\n";
|
||||
stream.end(Buffer.from(data));
|
||||
await once(stream, "end");
|
||||
expect(collector.getString()).toBe(data);
|
||||
expect(collector.hit).toBe(false);
|
||||
});
|
||||
|
||||
it("truncates data over byte limit", async () => {
|
||||
const stream = new PassThrough();
|
||||
const collector = createTruncatingCollector(stream, 5, 100);
|
||||
stream.end(Buffer.from("hello world"));
|
||||
await once(stream, "end");
|
||||
expect(collector.getString()).toBe("hello");
|
||||
expect(collector.hit).toBe(true);
|
||||
});
|
||||
|
||||
it("truncates data over line limit", async () => {
|
||||
const stream = new PassThrough();
|
||||
const collector = createTruncatingCollector(stream, 1000, 2);
|
||||
const data = "a\nb\nc\nd\n";
|
||||
stream.end(Buffer.from(data));
|
||||
await once(stream, "end");
|
||||
expect(collector.getString()).toBe("a\nb\n");
|
||||
expect(collector.hit).toBe(true);
|
||||
});
|
||||
|
||||
it("stops collecting after limit is hit across multiple writes", async () => {
|
||||
const stream = new PassThrough();
|
||||
const collector = createTruncatingCollector(stream, 10, 2);
|
||||
stream.write(Buffer.from("1\n"));
|
||||
stream.write(Buffer.from("2\n3\n4\n"));
|
||||
stream.end();
|
||||
await once(stream, "end");
|
||||
expect(collector.getString()).toBe("1\n2\n");
|
||||
expect(collector.hit).toBe(true);
|
||||
});
|
||||
|
||||
it("handles zero limits", async () => {
|
||||
const stream = new PassThrough();
|
||||
const collector = createTruncatingCollector(stream, 0, 0);
|
||||
stream.end(Buffer.from("anything\n"));
|
||||
await once(stream, "end");
|
||||
expect(collector.getString()).toBe("");
|
||||
expect(collector.hit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* codex-cli/tests/disableResponseStorage.agentLoop.test.ts
|
||||
*
|
||||
* Verifies AgentLoop's request-building logic for both values of
|
||||
* disableResponseStorage.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop";
|
||||
import type { AppConfig } from "../src/utils/config";
|
||||
import { ReviewDecision } from "../src/utils/agent/review";
|
||||
|
||||
/* ─────────── 1. Spy + module mock ─────────────────────────────── */
|
||||
const createSpy = vi.fn().mockResolvedValue({
|
||||
data: { id: "resp_123", status: "completed", output: [] },
|
||||
});
|
||||
|
||||
vi.mock("openai", () => ({
|
||||
default: class {
|
||||
public responses = { create: createSpy };
|
||||
},
|
||||
APIConnectionTimeoutError: class extends Error {},
|
||||
}));
|
||||
|
||||
/* ─────────── 2. Parametrised tests ─────────────────────────────── */
|
||||
describe.each([
|
||||
{ flag: true, title: "omits previous_response_id & sets store:false" },
|
||||
{ flag: false, title: "sends previous_response_id & allows store:true" },
|
||||
])("AgentLoop with disableResponseStorage=%s", ({ flag, title }) => {
|
||||
/* build a fresh config for each case */
|
||||
const cfg: AppConfig = {
|
||||
model: "codex-mini-latest",
|
||||
provider: "openai",
|
||||
instructions: "",
|
||||
disableResponseStorage: flag,
|
||||
notify: false,
|
||||
};
|
||||
|
||||
it(title, async () => {
|
||||
/* reset spy per iteration */
|
||||
createSpy.mockClear();
|
||||
|
||||
const loop = new AgentLoop({
|
||||
model: cfg.model,
|
||||
provider: cfg.provider,
|
||||
config: cfg,
|
||||
instructions: "",
|
||||
approvalPolicy: "suggest",
|
||||
disableResponseStorage: flag,
|
||||
additionalWritableRoots: [],
|
||||
onItem() {},
|
||||
onLoading() {},
|
||||
getCommandConfirmation: async () => ({ review: ReviewDecision.YES }),
|
||||
onLastResponseId() {},
|
||||
});
|
||||
|
||||
await loop.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = createSpy.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Expected createSpy to have been called at least once");
|
||||
}
|
||||
const payload: any = call[0];
|
||||
|
||||
if (flag) {
|
||||
/* behaviour when ZDR is *on* */
|
||||
expect(payload).not.toHaveProperty("previous_response_id");
|
||||
if (payload.input) {
|
||||
payload.input.forEach((m: any) => {
|
||||
expect(m.store === undefined ? false : m.store).toBe(false);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
/* behaviour when ZDR is *off* */
|
||||
expect(payload).toHaveProperty("previous_response_id");
|
||||
if (payload.input) {
|
||||
payload.input.forEach((m: any) => {
|
||||
if ("store" in m) {
|
||||
expect(m.store).not.toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* codex/codex-cli/tests/disableResponseStorage.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { loadConfig, saveConfig } from "../src/utils/config";
|
||||
import type { AppConfig } from "../src/utils/config";
|
||||
|
||||
const sandboxHome: string = mkdtempSync(join(tmpdir(), "codex-home-"));
|
||||
const codexDir: string = join(sandboxHome, ".codex");
|
||||
const yamlPath: string = join(codexDir, "config.yaml");
|
||||
|
||||
describe("disableResponseStorage persistence", () => {
|
||||
beforeAll((): void => {
|
||||
// mkdir -p ~/.codex inside the sandbox
|
||||
rmSync(codexDir, { recursive: true, force: true });
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
|
||||
// seed YAML with ZDR enabled
|
||||
writeFileSync(
|
||||
yamlPath,
|
||||
"model: codex-mini-latest\ndisableResponseStorage: true\n",
|
||||
);
|
||||
});
|
||||
|
||||
afterAll((): void => {
|
||||
rmSync(sandboxHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps disableResponseStorage=true across load/save cycle", async (): Promise<void> => {
|
||||
// 1️⃣ explicitly load the sandbox file
|
||||
const cfg1: AppConfig = loadConfig(yamlPath);
|
||||
expect(cfg1.disableResponseStorage).toBe(true);
|
||||
|
||||
// 2️⃣ save right back to the same file
|
||||
await saveConfig(cfg1, yamlPath);
|
||||
|
||||
// 3️⃣ reload and re-assert
|
||||
const cfg2: AppConfig = loadConfig(yamlPath);
|
||||
expect(cfg2.disableResponseStorage).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { test, expect } from "vitest";
|
||||
test("dummy", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { execApplyPatch } from "../src/utils/agent/exec.js";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { test, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* This test verifies that `execApplyPatch()` is able to add a new file whose
|
||||
* parent directory does not yet exist. Prior to the fix, the call would throw
|
||||
* because `fs.writeFileSync()` could not create intermediate directories. The
|
||||
* test creates an isolated temporary directory to avoid polluting the project
|
||||
* workspace.
|
||||
*/
|
||||
test("execApplyPatch creates missing directories when adding a file", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "apply-patch-test-"));
|
||||
|
||||
// Ensure we start from a clean slate.
|
||||
const nestedFileRel = path.join("foo", "bar", "baz.txt");
|
||||
const nestedFileAbs = path.join(tmpDir, nestedFileRel);
|
||||
expect(fs.existsSync(nestedFileAbs)).toBe(false);
|
||||
|
||||
const patch = `*** Begin Patch\n*** Add File: ${nestedFileRel}\n+hello new world\n*** End Patch`;
|
||||
|
||||
// Run execApplyPatch() with cwd switched to tmpDir so that the relative
|
||||
// path in the patch is resolved inside the temporary location.
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = execApplyPatch(patch);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
|
||||
// The file (and its parent directories) should have been created with the
|
||||
// expected contents.
|
||||
const fileContents = fs.readFileSync(nestedFileAbs, "utf8");
|
||||
expect(fileContents).toBe("hello new world");
|
||||
|
||||
// Cleanup to keep tmpdir tidy.
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { getFileSystemSuggestions } from "../src/utils/file-system-suggestions";
|
||||
|
||||
vi.mock("fs");
|
||||
vi.mock("os");
|
||||
|
||||
describe("getFileSystemSuggestions", () => {
|
||||
const mockFs = fs as unknown as {
|
||||
readdirSync: ReturnType<typeof vi.fn>;
|
||||
statSync: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const mockOs = os as unknown as {
|
||||
homedir: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty array for empty prefix", () => {
|
||||
expect(getFileSystemSuggestions("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("expands ~ to home directory", () => {
|
||||
mockOs.homedir = vi.fn(() => "/home/testuser");
|
||||
mockFs.readdirSync = vi.fn(() => ["file1.txt", "docs"]);
|
||||
mockFs.statSync = vi.fn((p) => ({
|
||||
isDirectory: () => path.basename(p) === "docs",
|
||||
}));
|
||||
|
||||
const result = getFileSystemSuggestions("~/");
|
||||
|
||||
expect(mockFs.readdirSync).toHaveBeenCalledWith("/home/testuser");
|
||||
expect(result).toEqual([
|
||||
{
|
||||
path: path.join("/home/testuser", "file1.txt"),
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
path: path.join("/home/testuser", "docs" + path.sep),
|
||||
isDirectory: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by prefix if not a directory", () => {
|
||||
mockFs.readdirSync = vi.fn(() => ["abc.txt", "abd.txt", "xyz.txt"]);
|
||||
mockFs.statSync = vi.fn((p) => ({
|
||||
isDirectory: () => p.includes("abd"),
|
||||
}));
|
||||
|
||||
const result = getFileSystemSuggestions("a");
|
||||
expect(result).toEqual([
|
||||
{
|
||||
path: "abc.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
path: "abd.txt/",
|
||||
isDirectory: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", () => {
|
||||
mockFs.readdirSync = vi.fn(() => {
|
||||
throw new Error("failed");
|
||||
});
|
||||
|
||||
const result = getFileSystemSuggestions("some/path");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes relative path", () => {
|
||||
mockFs.readdirSync = vi.fn(() => ["foo", "bar"]);
|
||||
mockFs.statSync = vi.fn((_p) => ({
|
||||
isDirectory: () => true,
|
||||
}));
|
||||
|
||||
const result = getFileSystemSuggestions("./");
|
||||
const paths = result.map((item) => item.path);
|
||||
const allDirectories = result.every((item) => item.isDirectory === true);
|
||||
|
||||
expect(paths).toContain("foo/");
|
||||
expect(paths).toContain("bar/");
|
||||
expect(allDirectories).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import {
|
||||
expandFileTags,
|
||||
collapseXmlBlocks,
|
||||
} from "../src/utils/file-tag-utils.js";
|
||||
|
||||
/**
|
||||
* Unit-tests for file tag utility functions:
|
||||
* - expandFileTags(): Replaces tokens like `@relative/path` with XML blocks containing file contents
|
||||
* - collapseXmlBlocks(): Reverses the expansion, converting XML blocks back to @path format
|
||||
*/
|
||||
|
||||
describe("expandFileTags", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-test-"));
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
beforeAll(() => {
|
||||
// Run the test from within the temporary directory so that the helper
|
||||
// generates relative paths that are predictable and isolated.
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("replaces @file token with XML wrapped contents", async () => {
|
||||
const filename = "hello.txt";
|
||||
const fileContent = "Hello, world!";
|
||||
fs.writeFileSync(path.join(tmpDir, filename), fileContent);
|
||||
|
||||
const input = `Please read @${filename}`;
|
||||
const output = await expandFileTags(input);
|
||||
|
||||
expect(output).toContain(`<${filename}>`);
|
||||
expect(output).toContain(fileContent);
|
||||
expect(output).toContain(`</${filename}>`);
|
||||
});
|
||||
|
||||
it("leaves token unchanged when file does not exist", async () => {
|
||||
const input = "This refers to @nonexistent.file";
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it("handles multiple @file tokens in one string", async () => {
|
||||
const fileA = "a.txt";
|
||||
const fileB = "b.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileA), "A content");
|
||||
fs.writeFileSync(path.join(tmpDir, fileB), "B content");
|
||||
const input = `@${fileA} and @${fileB}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain("A content");
|
||||
expect(output).toContain("B content");
|
||||
expect(output).toContain(`<${fileA}>`);
|
||||
expect(output).toContain(`<${fileB}>`);
|
||||
});
|
||||
|
||||
it("does not replace @dir if it's a directory", async () => {
|
||||
const dirName = "somedir";
|
||||
fs.mkdirSync(path.join(tmpDir, dirName));
|
||||
const input = `Check @${dirName}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain(`@${dirName}`);
|
||||
});
|
||||
|
||||
it("handles @file with special characters in name", async () => {
|
||||
const fileName = "weird-._~name.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "special chars");
|
||||
const input = `@${fileName}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain("special chars");
|
||||
expect(output).toContain(`<${fileName}>`);
|
||||
});
|
||||
|
||||
it("handles repeated @file tokens", async () => {
|
||||
const fileName = "repeat.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "repeat content");
|
||||
const input = `@${fileName} @${fileName}`;
|
||||
const output = await expandFileTags(input);
|
||||
// Both tags should be replaced
|
||||
expect(output.match(new RegExp(`<${fileName}>`, "g"))?.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles empty file", async () => {
|
||||
const fileName = "empty.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "");
|
||||
const input = `@${fileName}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain(`<${fileName}>\n\n</${fileName}>`);
|
||||
});
|
||||
|
||||
it("handles string with no @file tokens", async () => {
|
||||
const input = "No tags here.";
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseXmlBlocks", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-collapse-test-"));
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
beforeAll(() => {
|
||||
// Run the test from within the temporary directory so that the helper
|
||||
// generates relative paths that are predictable and isolated.
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("collapses XML block to @path format for valid file", () => {
|
||||
// Create a real file
|
||||
const fileName = "valid-file.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "file content");
|
||||
|
||||
const input = `<${fileName}>\nHello, world!\n</${fileName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${fileName}`);
|
||||
});
|
||||
|
||||
it("does not collapse XML block for unrelated xml block", () => {
|
||||
const xmlBlockName = "non-file-block";
|
||||
const input = `<${xmlBlockName}>\nContent here\n</${xmlBlockName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
// Should remain unchanged
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("does not collapse XML block for a directory", () => {
|
||||
// Create a directory
|
||||
const dirName = "test-dir";
|
||||
fs.mkdirSync(path.join(tmpDir, dirName), { recursive: true });
|
||||
|
||||
const input = `<${dirName}>\nThis is a directory\n</${dirName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
// Should remain unchanged
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("collapses multiple valid file XML blocks in one string", () => {
|
||||
// Create real files
|
||||
const fileA = "a.txt";
|
||||
const fileB = "b.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileA), "A content");
|
||||
fs.writeFileSync(path.join(tmpDir, fileB), "B content");
|
||||
|
||||
const input = `<${fileA}>\nA content\n</${fileA}> and <${fileB}>\nB content\n</${fileB}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${fileA} and @${fileB}`);
|
||||
});
|
||||
|
||||
it("only collapses valid file paths in mixed content", () => {
|
||||
// Create a real file
|
||||
const validFile = "valid.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, validFile), "valid content");
|
||||
const invalidFile = "invalid.txt";
|
||||
|
||||
const input = `<${validFile}>\nvalid content\n</${validFile}> and <${invalidFile}>\ninvalid content\n</${invalidFile}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(
|
||||
`@${validFile} and <${invalidFile}>\ninvalid content\n</${invalidFile}>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles paths with subdirectories for valid files", () => {
|
||||
// Create a nested file
|
||||
const nestedDir = "nested/path";
|
||||
const nestedFile = "nested/path/file.txt";
|
||||
fs.mkdirSync(path.join(tmpDir, nestedDir), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, nestedFile), "nested content");
|
||||
|
||||
const relPath = "nested/path/file.txt";
|
||||
const input = `<${relPath}>\nContent here\n</${relPath}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
const expectedPath = path.normalize(relPath);
|
||||
expect(output).toBe(`@${expectedPath}`);
|
||||
});
|
||||
|
||||
it("handles XML blocks with special characters in path for valid files", () => {
|
||||
// Create a file with special characters
|
||||
const specialFileName = "weird-._~name.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, specialFileName), "special chars");
|
||||
|
||||
const input = `<${specialFileName}>\nspecial chars\n</${specialFileName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${specialFileName}`);
|
||||
});
|
||||
|
||||
it("handles XML blocks with empty content for valid files", () => {
|
||||
// Create an empty file
|
||||
const emptyFileName = "empty.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, emptyFileName), "");
|
||||
|
||||
const input = `<${emptyFileName}>\n\n</${emptyFileName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${emptyFileName}`);
|
||||
});
|
||||
|
||||
it("handles string with no XML blocks", () => {
|
||||
const input = "No tags here.";
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("handles adjacent XML blocks for valid files", () => {
|
||||
// Create real files
|
||||
const adjFile1 = "adj1.txt";
|
||||
const adjFile2 = "adj2.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, adjFile1), "adj1");
|
||||
fs.writeFileSync(path.join(tmpDir, adjFile2), "adj2");
|
||||
|
||||
const input = `<${adjFile1}>\nadj1\n</${adjFile1}><${adjFile2}>\nadj2\n</${adjFile2}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${adjFile1}@${adjFile2}`);
|
||||
});
|
||||
|
||||
it("ignores malformed XML blocks", () => {
|
||||
const input = "<incomplete>content without closing tag";
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("handles mixed content with valid file XML blocks and regular text", () => {
|
||||
// Create a real file
|
||||
const mixedFile = "mixed-file.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, mixedFile), "file content");
|
||||
|
||||
const input = `This is <${mixedFile}>\nfile content\n</${mixedFile}> and some more text.`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`This is @${mixedFile} and some more text.`);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parse } from "shell-quote";
|
||||
|
||||
// The fixed requiresShell function
|
||||
function requiresShell(cmd: Array<string>): boolean {
|
||||
// If the command is a single string that contains shell operators,
|
||||
// it needs to be run with shell: true
|
||||
if (cmd.length === 1 && cmd[0] !== undefined) {
|
||||
const tokens = parse(cmd[0]) as Array<any>;
|
||||
return tokens.some((token) => typeof token === "object" && "op" in token);
|
||||
}
|
||||
|
||||
// If the command is split into multiple arguments, we don't need shell: true
|
||||
// even if one of the arguments is a shell operator like '|'
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("fixed requiresShell function", () => {
|
||||
it("should detect pipe in a single argument", () => {
|
||||
const cmd = ['grep -n "finally:" some-file | head'];
|
||||
expect(requiresShell(cmd)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not detect pipe in separate arguments", () => {
|
||||
const cmd = ["grep", "-n", "finally:", "some-file", "|", "head"];
|
||||
expect(requiresShell(cmd)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle other shell operators in a single argument", () => {
|
||||
const cmd = ["echo hello && echo world"];
|
||||
expect(requiresShell(cmd)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not enable shell for normal commands", () => {
|
||||
const cmd = ["ls", "-la"];
|
||||
expect(requiresShell(cmd)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { formatCommandForDisplay } from "../src/format-command";
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("formatCommandForDisplay()", () => {
|
||||
test("ensure empty string arg appears in output", () => {
|
||||
expect(formatCommandForDisplay(["echo", ""])).toEqual("echo ''");
|
||||
});
|
||||
|
||||
test("ensure special characters are properly escaped", () => {
|
||||
expect(formatCommandForDisplay(["echo", "$HOME"])).toEqual("echo \\$HOME");
|
||||
});
|
||||
|
||||
test("ensure quotes are properly escaped", () => {
|
||||
expect(formatCommandForDisplay(["echo", "I can't believe this."])).toEqual(
|
||||
'echo "I can\'t believe this."',
|
||||
);
|
||||
expect(
|
||||
formatCommandForDisplay(["echo", 'So I said, "No ma\'am!"']),
|
||||
).toEqual('echo "So I said, \\"No ma\'am\\!\\""');
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getGitDiff } from "../src/utils/get-diff.js";
|
||||
|
||||
describe("getGitDiff", () => {
|
||||
it("handles untracked files with special characters", () => {
|
||||
const repoDir = mkdtempSync(join(tmpdir(), "git-diff-test-"));
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(repoDir);
|
||||
execSync("git init", { stdio: "ignore" });
|
||||
|
||||
const fileName = "a$b.txt";
|
||||
writeFileSync(join(repoDir, fileName), "hello\n");
|
||||
|
||||
const { isGitRepo, diff } = getGitDiff();
|
||||
expect(isGitRepo).toBe(true);
|
||||
expect(diff).toContain(fileName);
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,350 +0,0 @@
|
||||
/* -------------------------------------------------------------------------- *
|
||||
* Tests for the HistoryOverlay component and its formatHistoryForDisplay utility function
|
||||
*
|
||||
* The component displays a list of commands and files from the chat history.
|
||||
* It supports two modes:
|
||||
* - Command mode: shows all commands and user messages
|
||||
* - File mode: shows all files that were touched
|
||||
*
|
||||
* The formatHistoryForDisplay function processes ResponseItems to extract:
|
||||
* - Commands: User messages and function calls
|
||||
* - Files: Paths referenced in commands or function calls
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "ink-testing-library";
|
||||
import React from "react";
|
||||
import type {
|
||||
ResponseInputMessageItem,
|
||||
ResponseFunctionToolCallItem,
|
||||
} from "openai/resources/responses/responses.mjs";
|
||||
import HistoryOverlay from "../src/components/history-overlay";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks *must* be registered *before* the module under test is imported
|
||||
// so that Vitest can replace the dependency during evaluation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock ink's useInput to capture keyboard handlers
|
||||
let keyboardHandler: ((input: string, key: any) => void) | undefined;
|
||||
vi.mock("ink", async () => {
|
||||
const actual = await vi.importActual("ink");
|
||||
return {
|
||||
...actual,
|
||||
useInput: (handler: (input: string, key: any) => void) => {
|
||||
keyboardHandler = handler;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createUserMessage(content: string): ResponseInputMessageItem {
|
||||
return {
|
||||
type: "message",
|
||||
role: "user",
|
||||
id: `msg_${Math.random().toString(36).slice(2)}`,
|
||||
content: [{ type: "input_text", text: content }],
|
||||
};
|
||||
}
|
||||
|
||||
function createFunctionCall(
|
||||
name: string,
|
||||
args: unknown,
|
||||
): ResponseFunctionToolCallItem {
|
||||
return {
|
||||
type: "function_call",
|
||||
name,
|
||||
id: `fn_${Math.random().toString(36).slice(2)}`,
|
||||
call_id: `call_${Math.random().toString(36).slice(2)}`,
|
||||
arguments: JSON.stringify(args),
|
||||
} as ResponseFunctionToolCallItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("HistoryOverlay", () => {
|
||||
describe("command mode", () => {
|
||||
it("displays user messages", () => {
|
||||
const items = [createUserMessage("hello"), createUserMessage("world")];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain("hello");
|
||||
expect(frame).toContain("world");
|
||||
});
|
||||
|
||||
it("displays shell commands", () => {
|
||||
const items = [
|
||||
createFunctionCall("shell", { cmd: ["ls", "-la"] }),
|
||||
createFunctionCall("shell", { cmd: ["pwd"] }),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain("ls -la");
|
||||
expect(frame).toContain("pwd");
|
||||
});
|
||||
|
||||
it("displays file operations", () => {
|
||||
const items = [createFunctionCall("read_file", { path: "test.txt" })];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain("read_file test.txt");
|
||||
});
|
||||
|
||||
it("displays patch operations", () => {
|
||||
const items = [
|
||||
createFunctionCall("shell", {
|
||||
cmd: [
|
||||
"apply_patch",
|
||||
"*** Begin Patch\n--- a/src/file1.txt\n+++ b/src/file1.txt\n@@ -1,5 +1,5 @@\n-const x = 1;\n+const x = 2;\n",
|
||||
],
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Verify patch is displayed in command mode
|
||||
let frame = lastFrame();
|
||||
expect(frame).toContain("apply_patch");
|
||||
expect(frame).toContain("src/file1.txt");
|
||||
|
||||
// Verify file is extracted in file mode
|
||||
keyboardHandler?.("f", {});
|
||||
frame = lastFrame();
|
||||
expect(frame).toContain("src/file1.txt");
|
||||
});
|
||||
|
||||
it("displays mixed content in chronological order", () => {
|
||||
const items = [
|
||||
createUserMessage("first message"),
|
||||
createFunctionCall("shell", { cmd: ["echo", "hello"] }),
|
||||
createUserMessage("second message"),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain("first message");
|
||||
expect(frame).toContain("echo hello");
|
||||
expect(frame).toContain("second message");
|
||||
});
|
||||
|
||||
it("truncates long user messages", () => {
|
||||
const shortMessage = "Hello";
|
||||
const longMessage =
|
||||
"This is a very long message that should be truncated because it exceeds the maximum length of 120 characters. We need to make sure it gets properly truncated with the right prefix and ellipsis.";
|
||||
const items = [
|
||||
createUserMessage(shortMessage),
|
||||
createUserMessage(longMessage),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
const frame = lastFrame()!;
|
||||
|
||||
// Short message should have the > prefix
|
||||
expect(frame).toContain(`> ${shortMessage}`);
|
||||
|
||||
// Long message should be truncated and contain:
|
||||
// 1. The > prefix
|
||||
expect(frame).toContain("> This is a very long message");
|
||||
// 2. An ellipsis indicating truncation
|
||||
expect(frame).toContain("…");
|
||||
// 3. Not contain the full message
|
||||
expect(frame).not.toContain(longMessage);
|
||||
|
||||
// Find the truncated message line
|
||||
const lines = frame.split("\n");
|
||||
const truncatedLine = lines.find((line) =>
|
||||
line.includes("This is a very long message"),
|
||||
)!;
|
||||
// Verify it's not too long (allowing for some UI elements)
|
||||
expect(truncatedLine.trim().length).toBeLessThan(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("file mode", () => {
|
||||
it("displays files from shell commands", () => {
|
||||
const items = [
|
||||
createFunctionCall("shell", { cmd: ["cat", "/path/to/file"] }),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Switch to file mode
|
||||
keyboardHandler?.("f", {});
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain("Files touched");
|
||||
expect(frame).toContain("/path/to/file");
|
||||
});
|
||||
|
||||
it("displays files from read operations", () => {
|
||||
const items = [
|
||||
createFunctionCall("read_file", { path: "/path/to/file" }),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Switch to file mode
|
||||
keyboardHandler?.("f", {});
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain("Files touched");
|
||||
expect(frame).toContain("/path/to/file");
|
||||
});
|
||||
|
||||
it("displays files from patches", () => {
|
||||
const items = [
|
||||
createFunctionCall("shell", {
|
||||
cmd: [
|
||||
"apply_patch",
|
||||
"*** Begin Patch\n--- a/src/file1.txt\n+++ b/src/file1.txt\n@@ -1,5 +1,5 @@\n-const x = 1;\n+const x = 2;\n",
|
||||
],
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Switch to file mode
|
||||
keyboardHandler?.("f", {});
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain("Files touched");
|
||||
expect(frame).toContain("src/file1.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard interaction", () => {
|
||||
it("handles mode switching with 'c' and 'f' keys", () => {
|
||||
const items = [
|
||||
createUserMessage("hello"),
|
||||
createFunctionCall("shell", { cmd: ["cat", "src/test.txt"] }),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Initial state (command mode)
|
||||
let frame = lastFrame();
|
||||
expect(frame).toContain("Commands run");
|
||||
expect(frame).toContain("hello");
|
||||
expect(frame).toContain("cat src/test.txt");
|
||||
|
||||
// Switch to files mode
|
||||
keyboardHandler?.("f", {});
|
||||
frame = lastFrame();
|
||||
expect(frame).toContain("Files touched");
|
||||
expect(frame).toContain("src/test.txt");
|
||||
|
||||
// Switch back to commands mode
|
||||
keyboardHandler?.("c", {});
|
||||
frame = lastFrame();
|
||||
expect(frame).toContain("Commands run");
|
||||
expect(frame).toContain("hello");
|
||||
expect(frame).toContain("cat src/test.txt");
|
||||
});
|
||||
|
||||
it("handles escape key", () => {
|
||||
const onExit = vi.fn();
|
||||
render(<HistoryOverlay items={[]} onExit={onExit} />);
|
||||
|
||||
keyboardHandler?.("", { escape: true });
|
||||
expect(onExit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles arrow keys for navigation", () => {
|
||||
const items = [createUserMessage("first"), createUserMessage("second")];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Initial state shows first item selected
|
||||
let frame = lastFrame();
|
||||
expect(frame).toContain("› > first");
|
||||
expect(frame).not.toContain("› > second");
|
||||
|
||||
// Move down - second item should be selected
|
||||
keyboardHandler?.("", { downArrow: true });
|
||||
frame = lastFrame();
|
||||
expect(frame).toContain("› > second");
|
||||
expect(frame).not.toContain("› > first");
|
||||
|
||||
// Move up - first item should be selected again
|
||||
keyboardHandler?.("", { upArrow: true });
|
||||
frame = lastFrame();
|
||||
expect(frame).toContain("› > first");
|
||||
expect(frame).not.toContain("› > second");
|
||||
});
|
||||
|
||||
it("handles page up/down navigation", () => {
|
||||
const items = Array.from({ length: 12 }, (_, i) =>
|
||||
createUserMessage(`message ${i + 1}`),
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Initial position - first message selected
|
||||
let frame = lastFrame();
|
||||
expect(frame).toMatch(/│ › > message 1\s+│/); // message 1 should be selected
|
||||
expect(frame).toMatch(/│ {3}> message 11\s+│/); // message 11 should be visible but not selected
|
||||
|
||||
// Page down moves by 10 - message 11 should be selected
|
||||
keyboardHandler?.("", { pageDown: true });
|
||||
frame = lastFrame();
|
||||
expect(frame).toMatch(/│ {3}> message 1\s+│/); // message 1 should be visible but not selected
|
||||
expect(frame).toMatch(/│ › > message 11\s+│/); // message 11 should be selected
|
||||
});
|
||||
|
||||
it("handles vim-style navigation", () => {
|
||||
const items = [
|
||||
createUserMessage("first"),
|
||||
createUserMessage("second"),
|
||||
createUserMessage("third"),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Initial state should show first item selected
|
||||
let frame = lastFrame();
|
||||
expect(frame).toContain("› > first");
|
||||
expect(frame).not.toContain("› > third"); // Make sure third is not selected initially
|
||||
|
||||
// Test G to jump to end - third should be selected
|
||||
keyboardHandler?.("G", {});
|
||||
frame = lastFrame();
|
||||
expect(frame).toContain("› > third");
|
||||
|
||||
// Test g to jump to beginning - first should be selected again
|
||||
keyboardHandler?.("g", {});
|
||||
frame = lastFrame();
|
||||
expect(frame).toContain("› > first");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles empty or invalid items", () => {
|
||||
const items = [{ type: "invalid" } as any, null as any, undefined as any];
|
||||
const { lastFrame } = render(
|
||||
<HistoryOverlay items={items} onExit={vi.fn()} />,
|
||||
);
|
||||
// Should render without errors
|
||||
expect(lastFrame()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import fs from "fs/promises";
|
||||
import { createInputItem } from "../src/utils/input-utils.js";
|
||||
|
||||
describe("createInputItem", () => {
|
||||
it("returns only text when no images provided", async () => {
|
||||
const result = await createInputItem("hello", []);
|
||||
expect(result).toEqual({
|
||||
role: "user",
|
||||
type: "message",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes image content for existing file", async () => {
|
||||
const fakeBuffer = Buffer.from("fake image content");
|
||||
const readSpy = vi
|
||||
.spyOn(fs, "readFile")
|
||||
.mockResolvedValue(fakeBuffer as any);
|
||||
const result = await createInputItem("hello", ["dummy-path"]);
|
||||
const expectedUrl = `data:application/octet-stream;base64,${fakeBuffer.toString(
|
||||
"base64",
|
||||
)}`;
|
||||
expect(result.role).toBe("user");
|
||||
expect(result.type).toBe("message");
|
||||
expect(result.content.length).toBe(2);
|
||||
const [textItem, imageItem] = result.content;
|
||||
expect(textItem).toEqual({ type: "input_text", text: "hello" });
|
||||
expect(imageItem).toEqual({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: expectedUrl,
|
||||
});
|
||||
readSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to missing image text for non-existent file", async () => {
|
||||
const filePath = "tests/__fixtures__/does-not-exist.png";
|
||||
const result = await createInputItem("hello", [filePath]);
|
||||
expect(result.content.length).toBe(2);
|
||||
const fallbackItem = result.content[1];
|
||||
expect(fallbackItem).toEqual({
|
||||
type: "input_text",
|
||||
text: "[missing image: does-not-exist.png]",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low‑level rawExec test ------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
|
||||
import type { AppConfig } from "../src/utils/config.js";
|
||||
describe("rawExec – invalid command handling", () => {
|
||||
it("resolves with non‑zero exit code when executable is missing", async () => {
|
||||
const cmd = ["definitely-not-a-command-1234567890"];
|
||||
const config = { model: "any", instructions: "" } as AppConfig;
|
||||
const result = await rawExec(cmd, {}, config);
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Higher‑level handleExecCommand test ----------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock approvals and logging helpers so the test focuses on execution flow.
|
||||
vi.mock("../src/approvals.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
canAutoApprove: () =>
|
||||
({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/format-command.js", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
import { handleExecCommand } from "../src/utils/agent/handle-exec-command.js";
|
||||
|
||||
describe("handleExecCommand – invalid executable", () => {
|
||||
it("returns non‑zero exit code for 'git show' as a single argv element", async () => {
|
||||
const execInput = { cmd: ["git show"] } as any;
|
||||
const config = { model: "any", instructions: "" } as any;
|
||||
const policy = { mode: "auto" } as any;
|
||||
const getConfirmation = async () => ({ review: "yes" }) as any;
|
||||
|
||||
const additionalWritableRoots: Array<string> = [];
|
||||
const { outputText, metadata } = await handleExecCommand(
|
||||
execInput,
|
||||
config,
|
||||
policy,
|
||||
additionalWritableRoots,
|
||||
getConfirmation,
|
||||
);
|
||||
|
||||
expect(metadata["exit_code"]).not.toBe(0);
|
||||
expect(String(outputText).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
import type { ColorSupportLevel } from "chalk";
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import { Markdown } from "../src/components/chat/terminal-chat-response-item.js";
|
||||
import React from "react";
|
||||
import { describe, afterEach, beforeEach, it, expect, vi } from "vitest";
|
||||
import chalk from "chalk";
|
||||
|
||||
const BOLD = "\x1B[1m";
|
||||
const BOLD_OFF = "\x1B[22m";
|
||||
const ITALIC = "\x1B[3m";
|
||||
const ITALIC_OFF = "\x1B[23m";
|
||||
const LINK_ON = "\x1B[4m";
|
||||
const LINK_OFF = "\x1B[24m";
|
||||
const BLUE = "\x1B[34m";
|
||||
const GREEN = "\x1B[32m";
|
||||
const YELLOW = "\x1B[33m";
|
||||
const COLOR_OFF = "\x1B[39m";
|
||||
|
||||
/** Simple sanity check that the Markdown component renders bold/italic text.
|
||||
* We strip ANSI codes, so the output should contain the raw words. */
|
||||
it("renders basic markdown", () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<Markdown fileOpener={undefined}>**bold** _italic_</Markdown>,
|
||||
);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("bold");
|
||||
expect(frame).toContain("italic");
|
||||
});
|
||||
|
||||
describe("ensure <Markdown> produces content with correct ANSI escape codes", () => {
|
||||
let chalkOriginalLevel: ColorSupportLevel = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
chalkOriginalLevel = chalk.level;
|
||||
chalk.level = 3;
|
||||
|
||||
vi.mock("supports-hyperlinks", () => ({
|
||||
default: {},
|
||||
supportsHyperlink: () => true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
chalk.level = chalkOriginalLevel;
|
||||
});
|
||||
|
||||
it("renders basic markdown with ansi", () => {
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={undefined}>**bold** _italic_</Markdown>,
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBe(`${BOLD}bold${BOLD_OFF} ${ITALIC}italic${ITALIC_OFF}`);
|
||||
});
|
||||
|
||||
// We had to patch in https://github.com/mikaelbr/marked-terminal/pull/366 to
|
||||
// make this work.
|
||||
it("bold test in a bullet should be rendered correctly", () => {
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={undefined}>* **bold** text</Markdown>,
|
||||
);
|
||||
|
||||
const outputWithAnsi = lastFrame();
|
||||
expect(outputWithAnsi).toBe(`* ${BOLD}bold${BOLD_OFF} text`);
|
||||
});
|
||||
|
||||
it("ensure simple nested list works as expected", () => {
|
||||
// Empirically, if there is no text at all before the first list item,
|
||||
// it gets indented.
|
||||
const nestedList = `\
|
||||
Paragraph before bulleted list.
|
||||
|
||||
* item 1
|
||||
* subitem 1
|
||||
* subitem 2
|
||||
* item 2
|
||||
`;
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={undefined}>{nestedList}</Markdown>,
|
||||
);
|
||||
|
||||
const outputWithAnsi = lastFrame();
|
||||
const i4 = " ".repeat(4);
|
||||
const expectedNestedList = `\
|
||||
Paragraph before bulleted list.
|
||||
|
||||
${i4}* item 1
|
||||
${i4}${i4}* subitem 1
|
||||
${i4}${i4}* subitem 2
|
||||
${i4}* item 2`;
|
||||
expect(outputWithAnsi).toBe(expectedNestedList);
|
||||
});
|
||||
|
||||
// We had to patch in https://github.com/mikaelbr/marked-terminal/pull/367 to
|
||||
// make this work.
|
||||
it("ensure sequential subitems with styling to do not get extra newlines", () => {
|
||||
// This is a real-world example that exhibits many of the Markdown features
|
||||
// we care about. Though the original issue fix this was intended to verify
|
||||
// was that even though there is a single newline between the two subitems,
|
||||
// the stock version of marked-terminal@7.3.0 was adding an extra newline
|
||||
// in the output.
|
||||
const nestedList = `\
|
||||
## 🛠 Core CLI Logic
|
||||
|
||||
All of the TypeScript/React code lives under \`src/\`. The main entrypoint for argument parsing and orchestration is:
|
||||
|
||||
### \`src/cli.tsx\`
|
||||
- Uses **meow** for flags/subcommands and prints the built-in help/usage:
|
||||
【F:src/cli.tsx†L49-L53】【F:src/cli.tsx†L55-L60】
|
||||
- Handles special subcommands (e.g. \`codex completion …\`), \`--config\`, API-key validation, then either:
|
||||
- Spawns the **AgentLoop** for the normal multi-step prompting/edits flow, or
|
||||
- Runs **single-pass** mode if \`--full-context\` is set.
|
||||
|
||||
`;
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={"vscode"} cwd="/home/user/codex">
|
||||
{nestedList}
|
||||
</Markdown>,
|
||||
);
|
||||
|
||||
const outputWithAnsi = lastFrame();
|
||||
|
||||
// Note that the line with two citations gets split across two lines.
|
||||
// While the underlying ANSI content is long such that the split appears to
|
||||
// be merited, the rendered output is considerably shorter and ideally it
|
||||
// would be a single line.
|
||||
const expectedNestedList = `${GREEN}${BOLD}## 🛠 Core CLI Logic${BOLD_OFF}${COLOR_OFF}
|
||||
|
||||
All of the TypeScript/React code lives under ${YELLOW}src/${COLOR_OFF}. The main entrypoint for argument parsing and
|
||||
orchestration is:
|
||||
|
||||
${GREEN}${BOLD}### ${YELLOW}src/cli.tsx${COLOR_OFF}${BOLD_OFF}
|
||||
|
||||
* Uses ${BOLD}meow${BOLD_OFF} for flags/subcommands and prints the built-in help/usage:
|
||||
${BLUE}src/cli.tsx:49 (${LINK_ON}vscode://file/home/user/codex/src/cli.tsx:49${LINK_OFF})${COLOR_OFF} ${BLUE}src/cli.tsx:55 ${COLOR_OFF}
|
||||
${BLUE}(${LINK_ON}vscode://file/home/user/codex/src/cli.tsx:55${LINK_OFF})${COLOR_OFF}
|
||||
* Handles special subcommands (e.g. ${YELLOW}codex completion …${COLOR_OFF}), ${YELLOW}--config${COLOR_OFF}, API-key validation, then
|
||||
either:
|
||||
* Spawns the ${BOLD}AgentLoop${BOLD_OFF} for the normal multi-step prompting/edits flow, or
|
||||
* Runs ${BOLD}single-pass${BOLD_OFF} mode if ${YELLOW}--full-context${COLOR_OFF} is set.`;
|
||||
|
||||
expect(toDiffableString(outputWithAnsi)).toBe(
|
||||
toDiffableString(expectedNestedList),
|
||||
);
|
||||
});
|
||||
|
||||
it("citations should get converted to hyperlinks when stdout supports them", () => {
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={"vscode"} cwd="/foo/bar">
|
||||
File with TODO: 【F:src/approvals.ts†L40】
|
||||
</Markdown>,
|
||||
);
|
||||
|
||||
const expected = `File with TODO: ${BLUE}src/approvals.ts:40 (${LINK_ON}vscode://file/foo/bar/src/approvals.ts:40${LINK_OFF})${COLOR_OFF}`;
|
||||
const outputWithAnsi = lastFrame();
|
||||
expect(outputWithAnsi).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
function toDiffableString(str: string) {
|
||||
// The test harness is not able to handle ANSI codes, so we need to escape
|
||||
// them, but still give it line-based input so that it can diff the output.
|
||||
return str
|
||||
.split("\n")
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join("\n");
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { openAiModelInfo } from "../src/utils/model-info";
|
||||
|
||||
describe("Model Info", () => {
|
||||
test("supportedModelInfo contains expected models", () => {
|
||||
expect(openAiModelInfo).toHaveProperty("gpt-4o");
|
||||
expect(openAiModelInfo).toHaveProperty("gpt-4.1");
|
||||
expect(openAiModelInfo).toHaveProperty("o3");
|
||||
});
|
||||
|
||||
test("model info entries have required properties", () => {
|
||||
Object.entries(openAiModelInfo).forEach(([_, info]) => {
|
||||
expect(info).toHaveProperty("label");
|
||||
expect(info).toHaveProperty("maxContextLength");
|
||||
expect(typeof info.label).toBe("string");
|
||||
expect(typeof info.maxContextLength).toBe("number");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
// The model‑utils module reads OPENAI_API_KEY at import time. We therefore
|
||||
// need to tweak the env var *before* importing the module in each test and
|
||||
// make sure the module cache is cleared.
|
||||
|
||||
const ORIGINAL_ENV_KEY = process.env["OPENAI_API_KEY"];
|
||||
|
||||
// Holders so individual tests can adjust behaviour of the OpenAI mock.
|
||||
const openAiState: { listSpy?: ReturnType<typeof vi.fn> } = {};
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public models = {
|
||||
// `listSpy` will be swapped out by the tests
|
||||
list: (...args: Array<any>) => openAiState.listSpy!(...args),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
};
|
||||
});
|
||||
|
||||
describe("model-utils – offline resilience", () => {
|
||||
afterEach(() => {
|
||||
// Restore env var & module cache so tests are isolated.
|
||||
if (ORIGINAL_ENV_KEY !== undefined) {
|
||||
process.env["OPENAI_API_KEY"] = ORIGINAL_ENV_KEY;
|
||||
} else {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
}
|
||||
vi.resetModules();
|
||||
openAiState.listSpy = undefined;
|
||||
});
|
||||
|
||||
it("returns true when API key absent (no network available)", async () => {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
|
||||
// Re‑import after env change so the module picks up the new state.
|
||||
vi.resetModules();
|
||||
const { isModelSupportedForResponses } = await import(
|
||||
"../src/utils/model-utils.js"
|
||||
);
|
||||
|
||||
const supported = await isModelSupportedForResponses(
|
||||
"openai",
|
||||
"codex-mini-latest",
|
||||
);
|
||||
expect(supported).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back gracefully when openai.models.list throws a network error", async () => {
|
||||
process.env["OPENAI_API_KEY"] = "dummy";
|
||||
|
||||
const netErr: any = new Error("socket hang up");
|
||||
netErr.code = "ECONNRESET";
|
||||
|
||||
openAiState.listSpy = vi.fn(async () => {
|
||||
throw netErr;
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { isModelSupportedForResponses } = await import(
|
||||
"../src/utils/model-utils.js"
|
||||
);
|
||||
|
||||
// Should resolve true despite the network failure.
|
||||
const supported = await isModelSupportedForResponses(
|
||||
"openai",
|
||||
"some-model",
|
||||
);
|
||||
expect(supported).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
calculateContextPercentRemaining,
|
||||
maxTokensForModel,
|
||||
} from "../src/utils/model-utils";
|
||||
import { openAiModelInfo } from "../src/utils/model-info";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
describe("Model Utils", () => {
|
||||
describe("openAiModelInfo", () => {
|
||||
test("model info entries have required properties", () => {
|
||||
Object.entries(openAiModelInfo).forEach(([_, info]) => {
|
||||
expect(info).toHaveProperty("label");
|
||||
expect(info).toHaveProperty("maxContextLength");
|
||||
expect(typeof info.label).toBe("string");
|
||||
expect(typeof info.maxContextLength).toBe("number");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxTokensForModel", () => {
|
||||
test("returns correct token limit for known models", () => {
|
||||
const knownModel = "gpt-4o";
|
||||
const expectedTokens = openAiModelInfo[knownModel].maxContextLength;
|
||||
expect(maxTokensForModel(knownModel)).toBe(expectedTokens);
|
||||
});
|
||||
|
||||
test("handles models with size indicators in their names", () => {
|
||||
expect(maxTokensForModel("some-model-32k")).toBe(32000);
|
||||
expect(maxTokensForModel("some-model-16k")).toBe(16000);
|
||||
expect(maxTokensForModel("some-model-8k")).toBe(8000);
|
||||
expect(maxTokensForModel("some-model-4k")).toBe(4000);
|
||||
});
|
||||
|
||||
test("defaults to 128k for unknown models not in the registry", () => {
|
||||
expect(maxTokensForModel("completely-unknown-model")).toBe(128000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateContextPercentRemaining", () => {
|
||||
test("returns 100% for empty items", () => {
|
||||
const result = calculateContextPercentRemaining([], "gpt-4o");
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
|
||||
test("calculates percentage correctly for non-empty items", () => {
|
||||
const mockItems: Array<ResponseItem> = [
|
||||
{
|
||||
id: "test-id",
|
||||
type: "message",
|
||||
role: "user",
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "A".repeat(
|
||||
openAiModelInfo["gpt-4o"].maxContextLength * 0.25 * 4,
|
||||
),
|
||||
},
|
||||
],
|
||||
} as ResponseItem,
|
||||
];
|
||||
|
||||
const result = calculateContextPercentRemaining(mockItems, "gpt-4o");
|
||||
expect(result).toBeCloseTo(75, 0);
|
||||
});
|
||||
|
||||
test("handles models that are not in the registry", () => {
|
||||
const mockItems: Array<ResponseItem> = [];
|
||||
|
||||
const result = calculateContextPercentRemaining(
|
||||
mockItems,
|
||||
"unknown-model",
|
||||
);
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// Ctrl+Enter (CSI‑u 13;5u) should submit the buffer.
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor – Ctrl+Enter submits", () => {
|
||||
it("calls onSubmit when CSI 13;5u is received", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
const { stdin, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
height: 5,
|
||||
width: 20,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
await flush();
|
||||
|
||||
await type(stdin, "hello", flush);
|
||||
await type(stdin, "\u001B[13;5u", flush); // Ctrl+Enter (modifier 5 = Ctrl)
|
||||
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]![0]).toBe("hello");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
// These tests exercise MultilineTextEditor behaviour when the editor width is
|
||||
// *not* provided via props so that it has to derive its width from the current
|
||||
// terminal size. We emulate a terminal‑resize by mutating
|
||||
// `process.stdout.columns` and emitting a synthetic `resize` event – the
|
||||
// `useTerminalSize` hook listens for that and causes the component to
|
||||
// re‑render. The test then asserts that
|
||||
// 1. The rendered line re‑wraps to the new width, *and*
|
||||
// 2. The caret (highlighted inverse character) is still kept in view after
|
||||
// the horizontal shrink so that editing remains possible.
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Helper to synchronously type text then flush Ink's timers so that the next
|
||||
// `lastFrame()` call sees the updated UI.
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor – dynamic width", () => {
|
||||
// The dynamic horizontal scroll logic is still flaky – mark as an expected
|
||||
// *failing* test so it doesn't break CI until the feature is aligned with
|
||||
// the Rust implementation.
|
||||
it("keeps the caret visible when the terminal width shrinks", async () => {
|
||||
// Fake an initial terminal width large enough that no horizontal
|
||||
// scrolling is required while we type the long alphabet sequence.
|
||||
process.stdout.columns = 40; // width seen by useTerminalSize (after padding)
|
||||
|
||||
const { stdin, lastFrame, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
initialText: "",
|
||||
// width *omitted* – component should fall back to terminal columns
|
||||
height: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
// Ensure initial render completes.
|
||||
await flush();
|
||||
|
||||
// Type the alphabet – longer than the width we'll shrink to.
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
||||
await type(stdin, alphabet, flush);
|
||||
|
||||
// The cursor (block) now sits on the far right after the 'z'. Verify that
|
||||
// the character 'z' is visible in the current frame.
|
||||
expect(lastFrame()?.includes("z")).toBe(true);
|
||||
|
||||
/* ----------------------- Simulate resize ----------------------- */
|
||||
|
||||
// Shrink the reported terminal width so that the previously visible slice
|
||||
// would no longer include the cursor *unless* the editor re‑computes
|
||||
// scroll offsets on re‑render.
|
||||
process.stdout.columns = 20; // shrink significantly (remember: padding‑8)
|
||||
process.stdout.emit("resize"); // notify listeners
|
||||
|
||||
// Allow Ink to schedule the state update and then perform the re‑render.
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// After the resize the editor should have scrolled horizontally so that
|
||||
// the caret (and thus the 'z' character that is block‑highlighted) remains
|
||||
// visible in the rendered slice.
|
||||
const frameAfter = lastFrame() || "";
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("FRAME AFTER RESIZE:\n" + frameAfter);
|
||||
expect(frameAfter.includes("z")).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// Plain Enter (CR) should submit.
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor – Enter submits (CR)", () => {
|
||||
it("calls onSubmit when \r is received", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
const { stdin, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
height: 5,
|
||||
width: 20,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
await flush();
|
||||
|
||||
await type(stdin, "hello", flush);
|
||||
await type(stdin, "\r", flush);
|
||||
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]![0]).toBe("hello");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
/* --------------------------------------------------------------------------
|
||||
* Regression test – chat history navigation (↑/↓) should *only* activate
|
||||
* once the caret reaches the very first / last line of the multiline input.
|
||||
*
|
||||
* Current buggy behaviour: TerminalChatInput intercepts the up‑arrow at the
|
||||
* outer <useInput> handler regardless of the caret row, causing an immediate
|
||||
* history recall even when the user is still somewhere within a multi‑line
|
||||
* draft. The test captures the *expected* behaviour (matching e.g. Bash,
|
||||
* zsh, Readline, etc.) – the ↑ key must first move the caret vertically to
|
||||
* the topmost row; only a *subsequent* press should start cycling through
|
||||
* previous messages.
|
||||
*
|
||||
* The spec is written *before* the fix so we mark it as an expected failure
|
||||
* (it.todo) until the implementation is aligned.
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks *must* be registered *before* the module under test is
|
||||
// imported so that Vitest can replace the dependency during evaluation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The chat‑input component relies on an async helper that performs filesystem
|
||||
// work when images are referenced. Mock it so our unit test remains fast and
|
||||
// free of side‑effects.
|
||||
vi.mock("../src/utils/input-utils.js", () => ({
|
||||
createInputItem: vi.fn(async (text: string /*, images: Array<string> */) => ({
|
||||
role: "user",
|
||||
type: "message",
|
||||
content: [{ type: "input_text", text }],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the optional ../src/* dependencies so the dynamic import in parsers.ts
|
||||
// does not fail during the test environment where the alias isn't configured.
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
}));
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
isSafeCommand: (_cmd: Array<string>) => null,
|
||||
}));
|
||||
|
||||
// After mocks are in place we can safely import the component under test.
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||
|
||||
// Tiny helper mirroring the one used in other UI tests so we can await Ink's
|
||||
// internal promises between keystrokes.
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
/** Build a set of no-op callbacks so <TerminalChatInput> renders with minimal
|
||||
* scaffolding.
|
||||
*/
|
||||
function stubProps(): any {
|
||||
return {
|
||||
isNew: true,
|
||||
loading: false,
|
||||
submitInput: vi.fn(),
|
||||
confirmationPrompt: null,
|
||||
submitConfirmation: vi.fn(),
|
||||
setLastResponseId: vi.fn(),
|
||||
// Cast to any to satisfy the generic React.Dispatch signature without
|
||||
// pulling the ResponseItem type into the test bundle.
|
||||
setItems: (() => {}) as any,
|
||||
contextLeftPercent: 100,
|
||||
openOverlay: vi.fn(),
|
||||
openModelOverlay: vi.fn(),
|
||||
openHelpOverlay: vi.fn(),
|
||||
interruptAgent: vi.fn(),
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TerminalChatInput – history navigation with multiline drafts", () => {
|
||||
it("should not recall history until caret is on the first line", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(TerminalChatInput, stubProps()),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. Submit one previous message so that history isn't empty.
|
||||
// -------------------------------------------------------------------
|
||||
for (const ch of ["p", "r", "e", "v"]) {
|
||||
await type(stdin, ch, flush);
|
||||
}
|
||||
await type(stdin, "\r", flush); // <Enter/Return> submits the text
|
||||
|
||||
// Let the async onSubmit finish (mocked so it's immediate, but flush once
|
||||
// more to allow state updates to propagate).
|
||||
await flush();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 2. Start a *multi‑line* draft so that the caret ends up on row 1.
|
||||
// -------------------------------------------------------------------
|
||||
await type(stdin, "line1", flush);
|
||||
await type(stdin, "\n", flush); // newline inside the editor (Shift+Enter)
|
||||
await type(stdin, "line2", flush);
|
||||
|
||||
// Sanity‑check – both lines should be visible in the current frame.
|
||||
const frameBefore = lastFrameStripped();
|
||||
expect(frameBefore.includes("line1")).toBe(true);
|
||||
expect(frameBefore.includes("line2")).toBe(true);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. Press ↑ once. Expected: caret moves from (row:1) -> (row:0) but
|
||||
// NO history recall yet, so the text stays unchanged.
|
||||
// -------------------------------------------------------------------
|
||||
await type(stdin, "\x1b[A", flush); // up‑arrow
|
||||
|
||||
const frameAfter = lastFrameStripped();
|
||||
|
||||
// The buffer should be unchanged – we *haven't* entered history‑navigation
|
||||
// mode yet because the caret only moved vertically inside the draft.
|
||||
expect(frameAfter.includes("prev")).toBe(false);
|
||||
expect(frameAfter.includes("line1")).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// TODO: Fix this test.
|
||||
it.skip("should restore the draft when navigating forward (↓) past the newest history entry", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(TerminalChatInput, stubProps()),
|
||||
);
|
||||
|
||||
// Submit one message so we have history to recall later.
|
||||
for (const ch of ["p", "r", "e", "v"]) {
|
||||
await type(stdin, ch, flush);
|
||||
}
|
||||
await type(stdin, "\r", flush); // <Enter> – submit
|
||||
await flush();
|
||||
|
||||
// Begin a multi‑line draft that we'll want to recover later.
|
||||
await type(stdin, "draft1", flush);
|
||||
await type(stdin, "\n", flush); // newline inside editor
|
||||
await type(stdin, "draft2", flush);
|
||||
|
||||
// Record the frame so we can later assert that it comes back.
|
||||
const draftFrame = lastFrameStripped();
|
||||
expect(draftFrame.includes("draft1")).toBe(true);
|
||||
expect(draftFrame.includes("draft2")).toBe(true);
|
||||
|
||||
// Before we start navigating upwards we must ensure the caret sits at
|
||||
// the very *start* of the current line. TerminalChatInput only engages
|
||||
// history recall when the cursor is positioned at row-0 *and* column-0
|
||||
// (mirroring the behaviour of shells like Bash/zsh or Readline). Hit
|
||||
// Ctrl+A (ASCII 0x01) to jump to SOL, then proceed with the ↑ presses.
|
||||
await type(stdin, "\x01", flush); // Ctrl+A – move to column-0
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 1) Hit ↑ twice: first press moves the caret from (row:1,col:0) to
|
||||
// (row:0,col:0); the *second* press now satisfies the gate for
|
||||
// history-navigation and should display the previous entry ("prev").
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
await type(stdin, "\x1b[A", flush); // first up – vertical move only
|
||||
await type(stdin, "\x1b[A", flush); // second up – recall history
|
||||
|
||||
const historyFrame = lastFrameStripped();
|
||||
expect(historyFrame.includes("prev")).toBe(true);
|
||||
|
||||
// 2) Hit ↓ once – should exit history mode and restore the original draft
|
||||
// (multi‑line input).
|
||||
await type(stdin, "\x1b[B", flush); // down‑arrow
|
||||
|
||||
const restoredFrame = lastFrameStripped();
|
||||
expect(restoredFrame.includes("draft1")).toBe(true);
|
||||
expect(restoredFrame.includes("draft2")).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Helper that lets us type and then immediately flush ink's async timers
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor", () => {
|
||||
it("renders the initial text", async () => {
|
||||
const { lastFrame, cleanup, waitUntilExit } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
initialText: "hello",
|
||||
width: 10,
|
||||
height: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitUntilExit(); // initial render
|
||||
expect(lastFrame()?.includes("hello")).toBe(true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("updates the buffer when typing and shows the change", async () => {
|
||||
const {
|
||||
stdin,
|
||||
lastFrame,
|
||||
cleanup,
|
||||
waitUntilExit: _,
|
||||
flush,
|
||||
} = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
initialText: "",
|
||||
width: 10,
|
||||
height: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
// Type "h"
|
||||
await type(stdin, "h", flush);
|
||||
expect(lastFrame()?.includes("h")).toBe(true);
|
||||
|
||||
// Type "i"
|
||||
await type(stdin, "i", flush);
|
||||
expect(lastFrame()?.includes("hi")).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("calls onSubmit with the current text on <Esc>", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
initialText: "foo",
|
||||
width: 10,
|
||||
height: 3,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
// Press Escape
|
||||
await type(stdin, "\x1b", flush);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith("foo");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("updates text when backspacing", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup, waitUntilExit } =
|
||||
renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
initialText: "",
|
||||
width: 10,
|
||||
height: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitUntilExit();
|
||||
|
||||
// Type "hello"
|
||||
stdin.write("hello");
|
||||
await flush();
|
||||
expect(lastFrameStripped().includes("hello")).toBe(true);
|
||||
|
||||
// Send 2× backspace (DEL / 0x7f)
|
||||
stdin.write("\x7f\x7f");
|
||||
await flush();
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame.includes("hel")).toBe(true);
|
||||
expect(frame.includes("hell")).toBe(false);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("three consecutive backspaces after typing 'hello' leaves 'he'", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup, waitUntilExit } =
|
||||
renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
initialText: "",
|
||||
width: 10,
|
||||
height: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitUntilExit();
|
||||
|
||||
stdin.write("hello");
|
||||
await flush();
|
||||
// 3 backspaces
|
||||
stdin.write("\x7f\x7f\x7f");
|
||||
await flush();
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame.includes("he")).toBe(true);
|
||||
expect(frame.includes("hel")).toBe(false);
|
||||
expect(frame.includes("hello")).toBe(false);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------- */
|
||||
/* Caret highlighting semantics */
|
||||
/* -------------------------------------------------------------- */
|
||||
|
||||
it("highlights the character *under* the caret (after arrow moves)", async () => {
|
||||
const { stdin, lastFrame, flush, cleanup, waitUntilExit } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
initialText: "",
|
||||
width: 10,
|
||||
height: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitUntilExit();
|
||||
|
||||
// Type "bar" and move caret left twice
|
||||
stdin.write("bar");
|
||||
stdin.write("\x1b[D");
|
||||
await flush();
|
||||
stdin.write("\x1b[D");
|
||||
await flush(); // ensure each arrow processed
|
||||
|
||||
const frameRaw = lastFrame() || "";
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("DEBUG frame:", frameRaw);
|
||||
const highlightedMatch = frameRaw.match(/\x1b\[7m(.)\x1b\[27m/);
|
||||
expect(highlightedMatch).not.toBeNull();
|
||||
const highlightedChar = highlightedMatch ? highlightedMatch[1] : null;
|
||||
|
||||
expect(highlightedChar).toBe("a"); // caret should block‑highlight 'a'
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Helper to send keystrokes and wait for Ink's async timing so that the frame
|
||||
// reflects the input.
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor – inserting new lines", () => {
|
||||
// Same as above – the React wrapper still differs from the Rust reference
|
||||
// when handling <Enter>. Keep the test around but mark it as expected to
|
||||
// fail.
|
||||
it("splits the line and renders the new row when <Enter> is pressed", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
height: 5,
|
||||
width: 20,
|
||||
initialText: "",
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for first render
|
||||
await flush();
|
||||
|
||||
// Type "hello", press Enter, then type "world"
|
||||
await type(stdin, "hello", flush);
|
||||
await type(stdin, "\n", flush); // Enter / Return
|
||||
await type(stdin, "world", flush);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
const lines = frame.split("\n");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"\n--- RENDERED FRAME ---\n" + frame + "\n---------------------",
|
||||
);
|
||||
|
||||
// We expect at least two rendered lines and the texts to appear on their
|
||||
// own respective rows.
|
||||
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||||
// First rendered (inside border) line should contain 'hello'
|
||||
expect(lines.some((l: string) => l.includes("hello"))).toBe(true);
|
||||
// Another line should contain 'world'
|
||||
expect(lines.some((l: string) => l.includes("world"))).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
// Regression test: Some terminals emit a carriage‑return ("\r") for
|
||||
// Shift+Enter instead of a bare line‑feed. Pressing Shift+Enter in that
|
||||
// environment should insert a newline **without** triggering submission.
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor - Shift+Enter (\r variant)", () => {
|
||||
it("inserts a newline and does NOT submit when the terminal sends \r for Shift+Enter", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
height: 5,
|
||||
width: 20,
|
||||
initialText: "",
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
await flush();
|
||||
|
||||
// Type some text then press Shift+Enter (simulated by kitty CSI-u seq).
|
||||
await type(stdin, "foo", flush);
|
||||
await type(stdin, "\u001B[13;2u", flush); // ESC [ 13 ; 2 u
|
||||
await type(stdin, "bar", flush);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toMatch(/foo/);
|
||||
expect(frame).toMatch(/bar/);
|
||||
|
||||
// Must have inserted a newline (two rendered lines inside the frame)
|
||||
expect(frame.split("\n").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// No submission should have occurred
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
// Regression test: Terminals with modifyOtherKeys=1 emit CSI~ sequence for
|
||||
// Shift+Enter: ESC [ 27 ; mod ; 13 ~. The editor must treat Shift+Enter as
|
||||
// newline (without submitting) and Ctrl+Enter as submit.
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor – Shift+Enter with modifyOtherKeys=1", () => {
|
||||
it("inserts newline, does NOT submit", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
height: 5,
|
||||
width: 20,
|
||||
initialText: "",
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
await flush();
|
||||
|
||||
await type(stdin, "abc", flush);
|
||||
// Shift+Enter => ESC [27;2;13~
|
||||
await type(stdin, "\u001B[27;2;13~", flush);
|
||||
await type(stdin, "def", flush);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toMatch(/abc/);
|
||||
expect(frame).toMatch(/def/);
|
||||
// newline inserted -> at least 2 lines
|
||||
expect(frame.split("\n").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import MultilineTextEditor from "../src/components/chat/multiline-editor.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("MultilineTextEditor – Shift+Enter", () => {
|
||||
it("inserts a newline instead of submitting", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(MultilineTextEditor, {
|
||||
height: 5,
|
||||
width: 20,
|
||||
initialText: "",
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
await flush();
|
||||
|
||||
// type 'hi'
|
||||
await type(stdin, "hi", flush);
|
||||
|
||||
// send Shift+Enter – simulated by \n without key.return. Ink's test stdin
|
||||
// delivers raw bytes only, so we approximate by writing "\n" directly.
|
||||
await type(stdin, "\n", flush);
|
||||
|
||||
// type 'there'
|
||||
await type(stdin, "there", flush);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toMatch(/hi/);
|
||||
expect(frame).toMatch(/there/);
|
||||
|
||||
// Shift+Enter must not trigger submission
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import which from "which";
|
||||
import { detectInstallerByPath } from "../src/utils/package-manager-detector";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
vi.mock("which", () => ({
|
||||
default: { sync: vi.fn() },
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({ execFileSync: vi.fn() }));
|
||||
|
||||
describe("detectInstallerByPath()", () => {
|
||||
const originalArgv = process.argv;
|
||||
const fakeBinDirs = {
|
||||
// `npm prefix -g` returns the global “prefix” (we’ll add `/bin` when detecting)
|
||||
npm: "/usr/local",
|
||||
pnpm: "/home/user/.local/share/pnpm/bin",
|
||||
bun: "/Users/test/.bun/bin",
|
||||
} as const;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Pretend each manager binary is on PATH:
|
||||
vi.mocked(which.sync).mockImplementation(() => "/fake/path");
|
||||
|
||||
vi.mocked(execFileSync).mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
_args: ReadonlyArray<string> = [],
|
||||
_options: unknown,
|
||||
): string => {
|
||||
return fakeBinDirs[cmd as keyof typeof fakeBinDirs];
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the real argv so tests don’t leak
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
it.each(Object.entries(fakeBinDirs))(
|
||||
"detects %s when invoked from its global-bin",
|
||||
async (manager, binDir) => {
|
||||
// Simulate the shim living under that binDir
|
||||
process.argv =
|
||||
manager === "npm"
|
||||
? [process.argv[0]!, `${binDir}/bin/my-cli`]
|
||||
: [process.argv[0]!, `${binDir}/my-cli`];
|
||||
const detected = await detectInstallerByPath();
|
||||
expect(detected).toBe(manager);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns undefined if argv[1] is missing", async () => {
|
||||
process.argv = [process.argv[0]!];
|
||||
expect(await detectInstallerByPath()).toBeUndefined();
|
||||
expect(execFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined if shim isn't in any manager's bin", async () => {
|
||||
// stub execFileSync to some other dirs
|
||||
vi.mocked(execFileSync).mockImplementation(() => "/some/other/dir");
|
||||
process.argv = [process.argv[0]!, "/home/user/.node_modules/.bin/my-cli"];
|
||||
expect(await detectInstallerByPath()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { parseApplyPatch } from "../src/parse-apply-patch";
|
||||
import { expect, test, describe } from "vitest";
|
||||
|
||||
// Helper function to unwrap a non‑null result in tests that expect success.
|
||||
function mustParse(patch: string) {
|
||||
const parsed = parseApplyPatch(patch);
|
||||
if (parsed == null) {
|
||||
throw new Error(
|
||||
"Expected patch to be valid, but parseApplyPatch returned null",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("parseApplyPatch", () => {
|
||||
test("parses create, update and delete operations in a single patch", () => {
|
||||
const patch = `*** Begin Patch\n*** Add File: created.txt\n+hello\n+world\n*** Update File: updated.txt\n@@\n-old\n+new\n*** Delete File: removed.txt\n*** End Patch`;
|
||||
|
||||
const ops = mustParse(patch);
|
||||
|
||||
expect(ops).toEqual([
|
||||
{
|
||||
type: "create",
|
||||
path: "created.txt",
|
||||
content: "hello\nworld",
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
path: "updated.txt",
|
||||
update: "@@\n-old\n+new",
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
},
|
||||
{
|
||||
type: "delete",
|
||||
path: "removed.txt",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null for an invalid patch (missing prefix)", () => {
|
||||
const invalid = `*** Add File: foo.txt\n+bar\n*** End Patch`;
|
||||
expect(parseApplyPatch(invalid)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parse } from "shell-quote";
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
describe("shell-quote parse with pipes", () => {
|
||||
it("should correctly parse a command with a pipe", () => {
|
||||
const cmd = 'grep -n "finally:" some-file | head';
|
||||
const tokens = parse(cmd);
|
||||
console.log("Parsed tokens:", JSON.stringify(tokens, null, 2));
|
||||
|
||||
// Check if any token has an 'op' property
|
||||
const hasOpToken = tokens.some(
|
||||
(token) => typeof token === "object" && "op" in token,
|
||||
);
|
||||
|
||||
expect(hasOpToken).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { loadConfig, PROJECT_DOC_MAX_BYTES } from "../src/utils/config.js";
|
||||
import { mkdirSync, rmSync, writeFileSync, mkdtempSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
let projectDir: string;
|
||||
let configPath: string;
|
||||
let instructionsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
|
||||
// Create fake .git dir to mark project root
|
||||
mkdirSync(join(projectDir, ".git"));
|
||||
|
||||
// Config & instructions paths under temp dir so we don't pollute real homedir
|
||||
configPath = join(projectDir, "config.json");
|
||||
instructionsPath = join(projectDir, "instructions.md");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("project doc integration", () => {
|
||||
test("happy path: project doc gets merged into instructions", () => {
|
||||
const docContent = "# Project\nThis is my project.";
|
||||
writeFileSync(join(projectDir, "codex.md"), docContent);
|
||||
|
||||
const cfg = loadConfig(configPath, instructionsPath, { cwd: projectDir });
|
||||
expect(cfg.instructions).toContain(docContent);
|
||||
});
|
||||
|
||||
test("opt-out via flag prevents inclusion", () => {
|
||||
const docContent = "will be ignored";
|
||||
writeFileSync(join(projectDir, "codex.md"), docContent);
|
||||
|
||||
const cfg = loadConfig(configPath, instructionsPath, {
|
||||
cwd: projectDir,
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
expect(cfg.instructions).not.toContain(docContent);
|
||||
});
|
||||
|
||||
test("file larger than limit gets truncated and warns", () => {
|
||||
const big = "x".repeat(PROJECT_DOC_MAX_BYTES + 4096);
|
||||
writeFileSync(join(projectDir, "codex.md"), big);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const cfg = loadConfig(configPath, instructionsPath, { cwd: projectDir });
|
||||
|
||||
expect(cfg.instructions.length).toBe(PROJECT_DOC_MAX_BYTES);
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
|
||||
import type { AppConfig } from "src/utils/config.js";
|
||||
|
||||
// Regression test: When cancelling an in‑flight `rawExec()` the implementation
|
||||
// must terminate *all* processes that belong to the spawned command – not just
|
||||
// the direct child. The original logic only sent `SIGTERM` to the immediate
|
||||
// child which meant that grandchildren (for instance when running through a
|
||||
// `bash -c` wrapper) were left running and turned into "zombie" processes.
|
||||
// Strategy:
|
||||
// 1. Start a Bash shell that spawns a long‑running `sleep`, prints the PID
|
||||
// of that `sleep`, and then waits forever. This guarantees we can later
|
||||
// check if the grand‑child is still alive.
|
||||
// 2. Abort the exec almost immediately.
|
||||
// 3. After `rawExec()` resolves we probe the previously printed PID with
|
||||
// `process.kill(pid, 0)`. If the call throws `ESRCH` the process no
|
||||
// longer exists – the desired outcome. Otherwise the test fails.
|
||||
// The negative‑PID process‑group trick employed by the fixed implementation is
|
||||
// POSIX‑only. On Windows we skip the test.
|
||||
|
||||
describe("rawExec – abort kills entire process group", () => {
|
||||
it("terminates grandchildren spawned via bash", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
// Bash script: spawn `sleep 30` in background, print its PID, then wait.
|
||||
const script = "sleep 30 & pid=$!; echo $pid; wait $pid";
|
||||
const cmd = ["bash", "-c", script];
|
||||
const config: AppConfig = {
|
||||
model: "test-model",
|
||||
instructions: "test-instructions",
|
||||
};
|
||||
|
||||
// Start a bash shell that:
|
||||
// - spawns a background `sleep 30`
|
||||
// - prints the PID of the `sleep`
|
||||
// - waits for `sleep` to exit
|
||||
const { stdout, exitCode } = await (async () => {
|
||||
const p = rawExec(cmd, {}, config, abortController.signal);
|
||||
|
||||
// Give Bash a tiny bit of time to start and print the PID.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Cancel the task – this should kill *both* bash and the inner sleep.
|
||||
abortController.abort();
|
||||
|
||||
// Wait for rawExec to resolve after aborting
|
||||
return p;
|
||||
})();
|
||||
|
||||
// We expect a non‑zero exit code because the process was killed.
|
||||
expect(exitCode).not.toBe(0);
|
||||
|
||||
// Extract the PID of the sleep process that bash printed
|
||||
const pid = Number(stdout.trim().match(/^\d+/)?.[0]);
|
||||
if (pid) {
|
||||
// Confirm that the sleep process is no longer alive
|
||||
await ensureProcessGone(pid);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Waits until a process no longer exists, or throws after timeout.
|
||||
* @param pid - The process ID to check
|
||||
* @throws {Error} If the process is still alive after 500ms
|
||||
*/
|
||||
async function ensureProcessGone(pid: number) {
|
||||
const timeout = 500;
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(pid, 0); // check if process still exists
|
||||
await new Promise((r) => setTimeout(r, 50)); // wait and retry
|
||||
} catch (e: any) {
|
||||
if (e.code === "ESRCH") {
|
||||
return; // process is gone — success
|
||||
}
|
||||
throw e; // unexpected error — rethrow
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Process with PID ${pid} failed to terminate within ${timeout}ms`,
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parse } from "shell-quote";
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
// Recreate the requiresShell function for testing
|
||||
function requiresShell(cmd: Array<string>): boolean {
|
||||
// If the command is a single string that contains shell operators,
|
||||
// it needs to be run with shell: true
|
||||
if (cmd.length === 1 && cmd[0] !== undefined) {
|
||||
const tokens = parse(cmd[0]) as Array<any>;
|
||||
console.log(
|
||||
`Parsing argument: "${cmd[0]}", tokens:`,
|
||||
JSON.stringify(tokens, null, 2),
|
||||
);
|
||||
return tokens.some((token) => typeof token === "object" && "op" in token);
|
||||
}
|
||||
|
||||
// If the command is split into multiple arguments, we don't need shell: true
|
||||
// even if one of the arguments is a shell operator like '|'
|
||||
cmd.forEach((arg) => {
|
||||
const tokens = parse(arg) as Array<any>;
|
||||
console.log(
|
||||
`Parsing argument: "${arg}", tokens:`,
|
||||
JSON.stringify(tokens, null, 2),
|
||||
);
|
||||
});
|
||||
console.log("Result for separate arguments: false");
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("requiresShell function", () => {
|
||||
it("should detect pipe in a single argument", () => {
|
||||
const cmd = ['grep -n "finally:" some-file | head'];
|
||||
expect(requiresShell(cmd)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not detect pipe in separate arguments", () => {
|
||||
const cmd = ["grep", "-n", "finally:", "some-file", "|", "head"];
|
||||
const result = requiresShell(cmd);
|
||||
console.log("Result for separate arguments:", result);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle other shell operators", () => {
|
||||
const cmd = ["echo hello && echo world"];
|
||||
expect(requiresShell(cmd)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,815 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import type { OpenAI } from "openai";
|
||||
import type {
|
||||
ResponseCreateInput,
|
||||
ResponseEvent,
|
||||
} from "../src/utils/responses";
|
||||
import type {
|
||||
ResponseInputItem,
|
||||
Tool,
|
||||
ResponseCreateParams,
|
||||
ResponseFunctionToolCallItem,
|
||||
ResponseFunctionToolCall,
|
||||
} from "openai/resources/responses/responses";
|
||||
|
||||
// Define specific types for streaming and non-streaming params
|
||||
type ResponseCreateParamsStreaming = ResponseCreateParams & { stream: true };
|
||||
type ResponseCreateParamsNonStreaming = ResponseCreateParams & {
|
||||
stream?: false;
|
||||
};
|
||||
|
||||
// Define additional type guard for tool calls done event
|
||||
type ToolCallsDoneEvent = Extract<
|
||||
ResponseEvent,
|
||||
{ type: "response.function_call_arguments.done" }
|
||||
>;
|
||||
type OutputTextDeltaEvent = Extract<
|
||||
ResponseEvent,
|
||||
{ type: "response.output_text.delta" }
|
||||
>;
|
||||
type OutputTextDoneEvent = Extract<
|
||||
ResponseEvent,
|
||||
{ type: "response.output_text.done" }
|
||||
>;
|
||||
type ResponseCompletedEvent = Extract<
|
||||
ResponseEvent,
|
||||
{ type: "response.completed" }
|
||||
>;
|
||||
|
||||
// Mock state to control the OpenAI client behavior
|
||||
const openAiState: {
|
||||
createSpy?: ReturnType<typeof vi.fn>;
|
||||
createStreamSpy?: ReturnType<typeof vi.fn>;
|
||||
} = {};
|
||||
|
||||
// Mock the OpenAI client
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public chat = {
|
||||
completions: {
|
||||
create: (...args: Array<any>) => {
|
||||
if (args[0]?.stream) {
|
||||
return openAiState.createStreamSpy!(...args);
|
||||
}
|
||||
return openAiState.createSpy!(...args);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FakeOpenAI,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper function to create properly typed test inputs
|
||||
function createTestInput(options: {
|
||||
model: string;
|
||||
userMessage: string;
|
||||
stream?: boolean;
|
||||
tools?: Array<Tool>;
|
||||
previousResponseId?: string;
|
||||
}): ResponseCreateInput {
|
||||
const message: ResponseInputItem.Message = {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text" as const,
|
||||
text: options.userMessage,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const input: ResponseCreateInput = {
|
||||
model: options.model,
|
||||
input: [message],
|
||||
};
|
||||
|
||||
if (options.stream !== undefined) {
|
||||
// @ts-expect-error TypeScript doesn't recognize this is valid
|
||||
input.stream = options.stream;
|
||||
}
|
||||
|
||||
if (options.tools) {
|
||||
input.tools = options.tools;
|
||||
}
|
||||
|
||||
if (options.previousResponseId) {
|
||||
input.previous_response_id = options.previousResponseId;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
// Type guard for function call content
|
||||
function isFunctionCall(content: any): content is ResponseFunctionToolCall {
|
||||
return (
|
||||
content && typeof content === "object" && content.type === "function_call"
|
||||
);
|
||||
}
|
||||
|
||||
// Additional type guard for tool call
|
||||
function isToolCall(item: any): item is ResponseFunctionToolCallItem {
|
||||
return item && typeof item === "object" && item.type === "function";
|
||||
}
|
||||
|
||||
// Type guards for various event types
|
||||
export function _isToolCallsDoneEvent(
|
||||
event: ResponseEvent,
|
||||
): event is ToolCallsDoneEvent {
|
||||
return event.type === "response.function_call_arguments.done";
|
||||
}
|
||||
|
||||
function isOutputTextDeltaEvent(
|
||||
event: ResponseEvent,
|
||||
): event is OutputTextDeltaEvent {
|
||||
return event.type === "response.output_text.delta";
|
||||
}
|
||||
|
||||
function isOutputTextDoneEvent(
|
||||
event: ResponseEvent,
|
||||
): event is OutputTextDoneEvent {
|
||||
return event.type === "response.output_text.done";
|
||||
}
|
||||
|
||||
function isResponseCompletedEvent(
|
||||
event: ResponseEvent,
|
||||
): event is ResponseCompletedEvent {
|
||||
return event.type === "response.completed";
|
||||
}
|
||||
|
||||
// Helper function to create a mock stream for tool calls testing
|
||||
function createToolCallsStream() {
|
||||
async function* fakeToolStream() {
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: { role: "assistant" },
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: { name: "get_weather" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: '{"location":"San Franci',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: 'sco"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
finish_reason: "tool_calls",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
};
|
||||
}
|
||||
|
||||
return fakeToolStream();
|
||||
}
|
||||
|
||||
describe("responsesCreateViaChatCompletions", () => {
|
||||
// Using any type here to avoid import issues
|
||||
let responsesModule: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
responsesModule = await import("../src/utils/responses");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
openAiState.createSpy = undefined;
|
||||
openAiState.createStreamSpy = undefined;
|
||||
});
|
||||
|
||||
describe("non-streaming mode", () => {
|
||||
it("should convert basic user message to chat completions format", async () => {
|
||||
// Setup mock response
|
||||
openAiState.createSpy = vi.fn().mockResolvedValue({
|
||||
id: "chat-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "This is a test response",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const openaiClient = new (await import("openai")).default({
|
||||
apiKey: "test-key",
|
||||
}) as unknown as OpenAI;
|
||||
|
||||
const inputMessage = createTestInput({
|
||||
model: "gpt-4o",
|
||||
userMessage: "Hello world",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const result = await responsesModule.responsesCreateViaChatCompletions(
|
||||
openaiClient,
|
||||
inputMessage as ResponseCreateParams & { stream?: false | undefined },
|
||||
);
|
||||
|
||||
// Verify OpenAI was called with correct parameters
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Skip type checking for mock objects in tests - this is acceptable for test code
|
||||
// @ts-ignore
|
||||
const callArgs = openAiState.createSpy?.mock?.calls?.[0]?.[0];
|
||||
if (callArgs) {
|
||||
expect(callArgs.model).toBe("gpt-4o");
|
||||
expect(callArgs.messages).toEqual([
|
||||
{ role: "user", content: "Hello world" },
|
||||
]);
|
||||
expect(callArgs.stream).toBe(false);
|
||||
}
|
||||
|
||||
// Verify result format
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.object).toBe("response");
|
||||
expect(result.model).toBe("gpt-4o");
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.output).toHaveLength(1);
|
||||
|
||||
// Use type guard to check the output item type
|
||||
const outputItem = result.output[0];
|
||||
expect(outputItem).toBeDefined();
|
||||
|
||||
if (outputItem && outputItem.type === "message") {
|
||||
expect(outputItem.role).toBe("assistant");
|
||||
expect(outputItem.content).toHaveLength(1);
|
||||
|
||||
const content = outputItem.content[0];
|
||||
if (content && content.type === "output_text") {
|
||||
expect(content.text).toBe("This is a test response");
|
||||
}
|
||||
}
|
||||
|
||||
expect(result.usage?.total_tokens).toBe(15);
|
||||
});
|
||||
|
||||
it("should handle function calling correctly", async () => {
|
||||
// Setup mock response with tool calls
|
||||
openAiState.createSpy = vi.fn().mockResolvedValue({
|
||||
id: "chat-456",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_abc123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: JSON.stringify({ location: "New York" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 8,
|
||||
total_tokens: 23,
|
||||
},
|
||||
});
|
||||
|
||||
const openaiClient = new (await import("openai")).default({
|
||||
apiKey: "test-key",
|
||||
}) as unknown as OpenAI;
|
||||
|
||||
// Define function tool correctly
|
||||
const weatherTool = {
|
||||
type: "function" as const,
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
};
|
||||
|
||||
const inputMessage = createTestInput({
|
||||
model: "gpt-4o",
|
||||
userMessage: "What's the weather in New York?",
|
||||
tools: [weatherTool as any],
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const result = await responsesModule.responsesCreateViaChatCompletions(
|
||||
openaiClient,
|
||||
inputMessage as ResponseCreateParams & { stream: false },
|
||||
);
|
||||
|
||||
// Verify OpenAI was called with correct parameters
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Skip type checking for mock objects in tests
|
||||
// @ts-ignore
|
||||
const callArgs = openAiState.createSpy?.mock?.calls?.[0]?.[0];
|
||||
if (callArgs) {
|
||||
expect(callArgs.model).toBe("gpt-4o");
|
||||
expect(callArgs.tools).toHaveLength(1);
|
||||
expect(callArgs.tools[0].function.name).toBe("get_weather");
|
||||
}
|
||||
|
||||
// Verify function call output directly instead of trying to check type
|
||||
expect(result.output).toHaveLength(1);
|
||||
|
||||
const outputItem = result.output[0];
|
||||
if (outputItem && outputItem.type === "message") {
|
||||
const content = outputItem.content[0];
|
||||
|
||||
// Use the type guard function
|
||||
expect(isFunctionCall(content)).toBe(true);
|
||||
|
||||
// Using type assertion after type guard check
|
||||
if (isFunctionCall(content)) {
|
||||
// These properties should exist on ResponseFunctionToolCall
|
||||
expect((content as any).name).toBe("get_weather");
|
||||
expect(JSON.parse((content as any).arguments).location).toBe(
|
||||
"New York",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve conversation history", async () => {
|
||||
// First interaction
|
||||
openAiState.createSpy = vi.fn().mockResolvedValue({
|
||||
id: "chat-789",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! How can I help you?",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 6, total_tokens: 11 },
|
||||
});
|
||||
|
||||
const openaiClient = new (await import("openai")).default({
|
||||
apiKey: "test-key",
|
||||
}) as unknown as OpenAI;
|
||||
|
||||
const firstInput = createTestInput({
|
||||
model: "gpt-4o",
|
||||
userMessage: "Hi there",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const firstResponse =
|
||||
await responsesModule.responsesCreateViaChatCompletions(
|
||||
openaiClient,
|
||||
firstInput as unknown as ResponseCreateParamsNonStreaming & {
|
||||
stream?: false | undefined;
|
||||
},
|
||||
);
|
||||
|
||||
// Reset the mock for second interaction
|
||||
openAiState.createSpy.mockReset();
|
||||
openAiState.createSpy = vi.fn().mockResolvedValue({
|
||||
id: "chat-790",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "I'm an AI assistant created by Anthropic.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 15, completion_tokens: 10, total_tokens: 25 },
|
||||
});
|
||||
|
||||
// Second interaction with previous_response_id
|
||||
const secondInput = createTestInput({
|
||||
model: "gpt-4o",
|
||||
userMessage: "Who are you?",
|
||||
previousResponseId: firstResponse.id,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
await responsesModule.responsesCreateViaChatCompletions(
|
||||
openaiClient,
|
||||
secondInput as unknown as ResponseCreateParamsNonStreaming & {
|
||||
stream?: false | undefined;
|
||||
},
|
||||
);
|
||||
|
||||
// Verify history was included in second call
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Skip type checking for mock objects in tests
|
||||
// @ts-ignore
|
||||
const secondCallArgs = openAiState.createSpy?.mock?.calls?.[0]?.[0];
|
||||
if (secondCallArgs) {
|
||||
// Should have 3 messages: original user, assistant response, and new user message
|
||||
expect(secondCallArgs.messages).toHaveLength(3);
|
||||
expect(secondCallArgs.messages[0].role).toBe("user");
|
||||
expect(secondCallArgs.messages[0].content).toBe("Hi there");
|
||||
expect(secondCallArgs.messages[1].role).toBe("assistant");
|
||||
expect(secondCallArgs.messages[1].content).toBe(
|
||||
"Hello! How can I help you?",
|
||||
);
|
||||
expect(secondCallArgs.messages[2].role).toBe("user");
|
||||
expect(secondCallArgs.messages[2].content).toBe("Who are you?");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles tools correctly", async () => {
|
||||
const testFunction = {
|
||||
type: "function" as const,
|
||||
name: "get_weather",
|
||||
description: "Get the weather",
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: {
|
||||
type: "string",
|
||||
description: "The location to get the weather for",
|
||||
},
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
};
|
||||
|
||||
// Mock response with a tool call
|
||||
openAiState.createSpy = vi.fn().mockResolvedValue({
|
||||
id: "chatcmpl-123",
|
||||
created: Date.now(),
|
||||
model: "gpt-4o",
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: JSON.stringify({ location: "San Francisco" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const openaiClient = new (await import("openai")).default({
|
||||
apiKey: "test-key",
|
||||
}) as unknown as OpenAI;
|
||||
|
||||
const inputMessage = createTestInput({
|
||||
model: "gpt-4o",
|
||||
userMessage: "What's the weather in San Francisco?",
|
||||
tools: [testFunction],
|
||||
});
|
||||
|
||||
const result = await responsesModule.responsesCreateViaChatCompletions(
|
||||
openaiClient,
|
||||
inputMessage as unknown as ResponseCreateParamsNonStreaming,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("requires_action");
|
||||
|
||||
// Cast result to include required_action to address TypeScript issues
|
||||
const resultWithAction = result as any;
|
||||
|
||||
// Add null checks for required_action
|
||||
expect(resultWithAction.required_action).not.toBeNull();
|
||||
expect(resultWithAction.required_action?.type).toBe(
|
||||
"submit_tool_outputs",
|
||||
);
|
||||
|
||||
// Safely access the tool calls with proper null checks
|
||||
const toolCalls =
|
||||
resultWithAction.required_action?.submit_tool_outputs?.tool_calls || [];
|
||||
expect(toolCalls.length).toBe(1);
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
const toolCall = toolCalls[0];
|
||||
expect(toolCall.type).toBe("function");
|
||||
|
||||
if (isToolCall(toolCall)) {
|
||||
// Access with type assertion after type guard
|
||||
expect((toolCall as any).function.name).toBe("get_weather");
|
||||
expect(JSON.parse((toolCall as any).function.arguments)).toEqual({
|
||||
location: "San Francisco",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only check model, messages, and tools in exact match
|
||||
expect(openAiState.createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "What's the weather in San Francisco?",
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: {
|
||||
type: "string",
|
||||
description: "The location to get the weather for",
|
||||
},
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streaming mode", () => {
|
||||
it("should handle streaming responses correctly", async () => {
|
||||
// Mock an async generator for streaming
|
||||
async function* fakeStream() {
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: { role: "assistant" },
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Hello" },
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: { content: " world" },
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: "chatcmpl-123",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
finish_reason: "stop",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
openAiState.createStreamSpy = vi.fn().mockResolvedValue(fakeStream());
|
||||
|
||||
const openaiClient = new (await import("openai")).default({
|
||||
apiKey: "test-key",
|
||||
}) as unknown as OpenAI;
|
||||
|
||||
const inputMessage = createTestInput({
|
||||
model: "gpt-4o",
|
||||
userMessage: "Say hello",
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const streamGenerator =
|
||||
await responsesModule.responsesCreateViaChatCompletions(
|
||||
openaiClient,
|
||||
inputMessage as unknown as ResponseCreateParamsStreaming & {
|
||||
stream: true;
|
||||
},
|
||||
);
|
||||
|
||||
// Collect all events from the stream
|
||||
const events: Array<ResponseEvent> = [];
|
||||
for await (const event of streamGenerator) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Verify stream generation
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// Check initial events
|
||||
const firstEvent = events[0];
|
||||
const secondEvent = events[1];
|
||||
expect(firstEvent?.type).toBe("response.created");
|
||||
expect(secondEvent?.type).toBe("response.in_progress");
|
||||
|
||||
// Find content delta events using proper type guard
|
||||
const deltaEvents = events.filter(isOutputTextDeltaEvent);
|
||||
|
||||
// Should have two delta events for "Hello" and " world"
|
||||
expect(deltaEvents).toHaveLength(2);
|
||||
expect(deltaEvents[0]?.delta).toBe("Hello");
|
||||
expect(deltaEvents[1]?.delta).toBe(" world");
|
||||
|
||||
// Check final completion event with type guard
|
||||
const completionEvent = events.find(isResponseCompletedEvent);
|
||||
expect(completionEvent).toBeDefined();
|
||||
if (completionEvent) {
|
||||
expect(completionEvent.response.status).toBe("completed");
|
||||
}
|
||||
|
||||
// Text should be concatenated
|
||||
const textDoneEvent = events.find(isOutputTextDoneEvent);
|
||||
expect(textDoneEvent).toBeDefined();
|
||||
if (textDoneEvent) {
|
||||
expect(textDoneEvent.text).toBe("Hello world");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles streaming with tool calls", async () => {
|
||||
// Mock a streaming response with tool calls
|
||||
const mockStream = createToolCallsStream();
|
||||
openAiState.createStreamSpy = vi.fn().mockReturnValue(mockStream);
|
||||
|
||||
const openaiClient = new (await import("openai")).default({
|
||||
apiKey: "test-key",
|
||||
}) as unknown as OpenAI;
|
||||
|
||||
const testFunction = {
|
||||
type: "function" as const,
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
};
|
||||
|
||||
const inputMessage = createTestInput({
|
||||
model: "gpt-4o",
|
||||
userMessage: "What's the weather in San Francisco?",
|
||||
tools: [testFunction],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const streamGenerator =
|
||||
await responsesModule.responsesCreateViaChatCompletions(
|
||||
openaiClient,
|
||||
inputMessage as unknown as ResponseCreateParamsStreaming,
|
||||
);
|
||||
|
||||
// Collect all events from the stream
|
||||
const events: Array<ResponseEvent> = [];
|
||||
for await (const event of streamGenerator) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Verify stream generation
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// Look for function call related events of any type related to tool calls
|
||||
const toolCallEvents = events.filter(
|
||||
(event) =>
|
||||
event.type.includes("function_call") ||
|
||||
event.type.includes("tool") ||
|
||||
(event.type === "response.output_item.added" &&
|
||||
"item" in event &&
|
||||
event.item?.type === "function_call"),
|
||||
);
|
||||
|
||||
expect(toolCallEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Check if we have the completed event which should contain the final result
|
||||
const completedEvent = events.find(isResponseCompletedEvent);
|
||||
expect(completedEvent).toBeDefined();
|
||||
|
||||
if (completedEvent) {
|
||||
// Get the function call from the output array
|
||||
const functionCallItem = completedEvent.response.output.find(
|
||||
(item) => item.type === "function_call",
|
||||
);
|
||||
expect(functionCallItem).toBeDefined();
|
||||
|
||||
if (functionCallItem && functionCallItem.type === "function_call") {
|
||||
expect(functionCallItem.name).toBe("get_weather");
|
||||
// The arguments is a JSON string, but we can check if it includes San Francisco
|
||||
expect(functionCallItem.arguments).toContain("San Francisco");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { SLASH_COMMANDS, type SlashCommand } from "../src/utils/slash-commands";
|
||||
|
||||
test("SLASH_COMMANDS includes expected commands", () => {
|
||||
const commands = SLASH_COMMANDS.map((c: SlashCommand) => c.command);
|
||||
expect(commands).toContain("/clear");
|
||||
expect(commands).toContain("/compact");
|
||||
expect(commands).toContain("/history");
|
||||
expect(commands).toContain("/sessions");
|
||||
expect(commands).toContain("/help");
|
||||
expect(commands).toContain("/model");
|
||||
expect(commands).toContain("/approval");
|
||||
expect(commands).toContain("/clearhistory");
|
||||
expect(commands).toContain("/diff");
|
||||
});
|
||||
|
||||
test("filters slash commands by prefix", () => {
|
||||
const prefix = "/c";
|
||||
const filtered = SLASH_COMMANDS.filter((c: SlashCommand) =>
|
||||
c.command.startsWith(prefix),
|
||||
);
|
||||
const names = filtered.map((c: SlashCommand) => c.command);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(["/clear", "/clearhistory", "/compact"]),
|
||||
);
|
||||
expect(names).not.toEqual(
|
||||
expect.arrayContaining(["/history", "/help", "/model", "/approval"]),
|
||||
);
|
||||
|
||||
const emptyPrefixFiltered = SLASH_COMMANDS.filter((c: SlashCommand) =>
|
||||
c.command.startsWith(""),
|
||||
);
|
||||
const emptyPrefixNames = emptyPrefixFiltered.map(
|
||||
(c: SlashCommand) => c.command,
|
||||
);
|
||||
expect(emptyPrefixNames).toEqual(
|
||||
expect.arrayContaining(SLASH_COMMANDS.map((c: SlashCommand) => c.command)),
|
||||
);
|
||||
expect(emptyPrefixNames).toHaveLength(SLASH_COMMANDS.length);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ComponentProps } from "react";
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import TerminalChatCompletions from "../src/components/chat/terminal-chat-completions.js";
|
||||
|
||||
describe("TerminalChatCompletions", () => {
|
||||
const baseProps: ComponentProps<typeof TerminalChatCompletions> = {
|
||||
completions: ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"],
|
||||
displayLimit: 3,
|
||||
selectedCompletion: 0,
|
||||
};
|
||||
|
||||
it("renders visible completions within displayLimit", async () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<TerminalChatCompletions {...baseProps} />,
|
||||
);
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("Option 1");
|
||||
expect(frame).toContain("Option 2");
|
||||
expect(frame).toContain("Option 3");
|
||||
expect(frame).not.toContain("Option 4");
|
||||
});
|
||||
|
||||
it("centers the selected completion in the visible list", async () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<TerminalChatCompletions {...baseProps} selectedCompletion={2} />,
|
||||
);
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("Option 2");
|
||||
expect(frame).toContain("Option 3");
|
||||
expect(frame).toContain("Option 4");
|
||||
expect(frame).not.toContain("Option 1");
|
||||
});
|
||||
|
||||
it("adjusts when selectedCompletion is near the end", async () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<TerminalChatCompletions {...baseProps} selectedCompletion={4} />,
|
||||
);
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("Option 3");
|
||||
expect(frame).toContain("Option 4");
|
||||
expect(frame).toContain("Option 5");
|
||||
expect(frame).not.toContain("Option 2");
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("TerminalChatInput compact command", () => {
|
||||
it("shows /compact hint when context is low", async () => {
|
||||
const props: ComponentProps<typeof TerminalChatInput> = {
|
||||
isNew: false,
|
||||
loading: false,
|
||||
submitInput: () => {},
|
||||
confirmationPrompt: null,
|
||||
explanation: undefined,
|
||||
submitConfirmation: () => {},
|
||||
setLastResponseId: () => {},
|
||||
setItems: () => {},
|
||||
contextLeftPercent: 10,
|
||||
openOverlay: () => {},
|
||||
openDiffOverlay: () => {},
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
};
|
||||
const { lastFrameStripped } = renderTui(<TerminalChatInput {...props} />);
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("/compact");
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import React from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Helper function for typing and flushing
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to reliably trigger file system suggestions in tests.
|
||||
*
|
||||
* This function simulates typing '@' followed by Tab to ensure suggestions appear.
|
||||
*
|
||||
* In real usage, simply typing '@' does trigger suggestions correctly.
|
||||
*/
|
||||
async function typeFileTag(
|
||||
stdin: NodeJS.WritableStream,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
// Type @ character
|
||||
stdin.write("@");
|
||||
await flush();
|
||||
|
||||
stdin.write("\t");
|
||||
await flush();
|
||||
}
|
||||
|
||||
// Mock the file system suggestions utility
|
||||
vi.mock("../src/utils/file-system-suggestions.js", () => ({
|
||||
FileSystemSuggestion: class {}, // Mock the interface
|
||||
getFileSystemSuggestions: vi.fn((pathPrefix: string) => {
|
||||
const normalizedPrefix = pathPrefix.startsWith("./")
|
||||
? pathPrefix.slice(2)
|
||||
: pathPrefix;
|
||||
const allItems = [
|
||||
{ path: "file1.txt", isDirectory: false },
|
||||
{ path: "file2.js", isDirectory: false },
|
||||
{ path: "directory1/", isDirectory: true },
|
||||
{ path: "directory2/", isDirectory: true },
|
||||
];
|
||||
return allItems.filter((item) => item.path.startsWith(normalizedPrefix));
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the createInputItem function to avoid filesystem operations
|
||||
vi.mock("../src/utils/input-utils.js", () => ({
|
||||
createInputItem: vi.fn(async (text: string) => ({
|
||||
role: "user",
|
||||
type: "message",
|
||||
content: [{ type: "input_text", text }],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("TerminalChatInput file tag suggestions", () => {
|
||||
// Standard props for all tests
|
||||
const baseProps: ComponentProps<typeof TerminalChatInput> = {
|
||||
isNew: false,
|
||||
loading: false,
|
||||
submitInput: vi.fn().mockImplementation(() => {}),
|
||||
confirmationPrompt: null,
|
||||
explanation: undefined,
|
||||
submitConfirmation: vi.fn(),
|
||||
setLastResponseId: vi.fn(),
|
||||
setItems: vi.fn(),
|
||||
contextLeftPercent: 50,
|
||||
openOverlay: vi.fn(),
|
||||
openDiffOverlay: vi.fn(),
|
||||
openModelOverlay: vi.fn(),
|
||||
openApprovalOverlay: vi.fn(),
|
||||
openHelpOverlay: vi.fn(),
|
||||
openSessionsOverlay: vi.fn(),
|
||||
onCompact: vi.fn(),
|
||||
interruptAgent: vi.fn(),
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows file system suggestions when typing @ alone", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Check that current directory suggestions are shown
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("file1.txt");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("completes the selected file system suggestion with Tab", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Press Tab to select the first suggestion
|
||||
await type(stdin, "\t", flush);
|
||||
|
||||
// Check that the input has been completed with the selected suggestion
|
||||
const frameAfterTab = lastFrameStripped();
|
||||
expect(frameAfterTab).toContain("@file1.txt");
|
||||
// Check that the rest of the suggestions have collapsed
|
||||
expect(frameAfterTab).not.toContain("file2.txt");
|
||||
expect(frameAfterTab).not.toContain("directory2/");
|
||||
expect(frameAfterTab).not.toContain("directory1/");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("clears file system suggestions when typing a space", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Check that suggestions are shown
|
||||
let frame = lastFrameStripped();
|
||||
expect(frame).toContain("file1.txt");
|
||||
|
||||
// Type a space to clear suggestions
|
||||
await type(stdin, " ", flush);
|
||||
|
||||
// Check that suggestions are cleared
|
||||
frame = lastFrameStripped();
|
||||
expect(frame).not.toContain("file1.txt");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("selects and retains directory when pressing Enter on directory suggestion", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Navigate to directory suggestion (we need two down keys to get to the first directory)
|
||||
await type(stdin, "\u001B[B", flush); // Down arrow key - move to file2.js
|
||||
await type(stdin, "\u001B[B", flush); // Down arrow key - move to directory1/
|
||||
|
||||
// Check that the directory suggestion is selected
|
||||
let frame = lastFrameStripped();
|
||||
expect(frame).toContain("directory1/");
|
||||
|
||||
// Press Enter to select the directory
|
||||
await type(stdin, "\r", flush);
|
||||
|
||||
// Check that the input now contains the directory path
|
||||
frame = lastFrameStripped();
|
||||
expect(frame).toContain("@directory1/");
|
||||
|
||||
// Check that submitInput was NOT called (since we're only navigating, not submitting)
|
||||
expect(baseProps.submitInput).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("submits when pressing Enter on file suggestion", async () => {
|
||||
const { stdin, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Press Enter to select first suggestion (file1.txt)
|
||||
await type(stdin, "\r", flush);
|
||||
|
||||
// Check that submitInput was called
|
||||
expect(baseProps.submitInput).toHaveBeenCalled();
|
||||
|
||||
// Get the arguments passed to submitInput
|
||||
const submitArgs = (baseProps.submitInput as any).mock.calls[0][0];
|
||||
|
||||
// Verify the first argument is an array with at least one item
|
||||
expect(Array.isArray(submitArgs)).toBe(true);
|
||||
expect(submitArgs.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that the content includes the file path
|
||||
const content = submitArgs[0].content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
expect(content[0].text).toContain("@file1.txt");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import React from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Helper that lets us type and then immediately flush ink's async timers
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
// Mock the createInputItem function to avoid filesystem operations
|
||||
vi.mock("../src/utils/input-utils.js", () => ({
|
||||
createInputItem: vi.fn(async (text: string) => ({
|
||||
role: "user",
|
||||
type: "message",
|
||||
content: [{ type: "input_text", text }],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("TerminalChatInput multiline functionality", () => {
|
||||
it("allows multiline input with shift+enter", async () => {
|
||||
const submitInput = vi.fn();
|
||||
|
||||
const props: ComponentProps<typeof TerminalChatInput> = {
|
||||
isNew: false,
|
||||
loading: false,
|
||||
submitInput,
|
||||
confirmationPrompt: null,
|
||||
explanation: undefined,
|
||||
submitConfirmation: () => {},
|
||||
setLastResponseId: () => {},
|
||||
setItems: () => {},
|
||||
contextLeftPercent: 50,
|
||||
openOverlay: () => {},
|
||||
openDiffOverlay: () => {},
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
};
|
||||
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...props} />,
|
||||
);
|
||||
|
||||
// Type some text
|
||||
await type(stdin, "first line", flush);
|
||||
|
||||
// Send Shift+Enter (CSI-u format)
|
||||
await type(stdin, "\u001B[13;2u", flush);
|
||||
|
||||
// Type more text
|
||||
await type(stdin, "second line", flush);
|
||||
|
||||
// Check that both lines are visible in the editor
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("first line");
|
||||
expect(frame).toContain("second line");
|
||||
|
||||
// Submit the multiline input with Enter
|
||||
await type(stdin, "\r", flush);
|
||||
|
||||
// Check that submitInput was called with the multiline text
|
||||
expect(submitInput).toHaveBeenCalledTimes(1);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("allows multiline input with shift+enter (modifyOtherKeys=1 format)", async () => {
|
||||
const submitInput = vi.fn();
|
||||
|
||||
const props: ComponentProps<typeof TerminalChatInput> = {
|
||||
isNew: false,
|
||||
loading: false,
|
||||
submitInput,
|
||||
confirmationPrompt: null,
|
||||
explanation: undefined,
|
||||
submitConfirmation: () => {},
|
||||
setLastResponseId: () => {},
|
||||
setItems: () => {},
|
||||
contextLeftPercent: 50,
|
||||
openOverlay: () => {},
|
||||
openDiffOverlay: () => {},
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
};
|
||||
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...props} />,
|
||||
);
|
||||
|
||||
// Type some text
|
||||
await type(stdin, "first line", flush);
|
||||
|
||||
// Send Shift+Enter (modifyOtherKeys=1 format)
|
||||
await type(stdin, "\u001B[27;2;13~", flush);
|
||||
|
||||
// Type more text
|
||||
await type(stdin, "second line", flush);
|
||||
|
||||
// Check that both lines are visible in the editor
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("first line");
|
||||
expect(frame).toContain("second line");
|
||||
|
||||
// Submit the multiline input with Enter
|
||||
await type(stdin, "\r", flush);
|
||||
|
||||
// Check that submitInput was called with the multiline text
|
||||
expect(submitInput).toHaveBeenCalledTimes(1);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import chalk from "chalk";
|
||||
import ModelOverlay from "src/components/model-overlay.js";
|
||||
|
||||
// Mock the necessary dependencies
|
||||
vi.mock("../src/utils/logger/log.js", () => ({
|
||||
log: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("chalk", () => ({
|
||||
default: {
|
||||
bold: {
|
||||
red: vi.fn((msg) => `[bold-red]${msg}[/bold-red]`),
|
||||
},
|
||||
yellow: vi.fn((msg) => `[yellow]${msg}[/yellow]`),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Model Selection Error Handling", () => {
|
||||
// Create a console.error spy with proper typing
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should display error with chalk formatting when selecting unavailable model", () => {
|
||||
// Setup
|
||||
const allModels = ["gpt-4", "gpt-3.5-turbo"];
|
||||
const currentModel = "gpt-4";
|
||||
const unavailableModel = "gpt-invalid";
|
||||
const currentProvider = "openai";
|
||||
|
||||
renderTui(
|
||||
<ModelOverlay
|
||||
currentModel={currentModel}
|
||||
providers={{ openai: { name: "OpenAI", baseURL: "", envKey: "test" } }}
|
||||
currentProvider={currentProvider}
|
||||
hasLastResponse={false}
|
||||
onSelect={(models, newModel) => {
|
||||
if (!models?.includes(newModel)) {
|
||||
console.error(
|
||||
chalk.bold.red(
|
||||
`Model "${chalk.yellow(
|
||||
newModel,
|
||||
)}" is not available for provider "${chalk.yellow(
|
||||
currentProvider,
|
||||
)}".`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onSelectProvider={() => {}}
|
||||
onExit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const onSelectHandler = vi.fn((models, newModel) => {
|
||||
if (!models?.includes(newModel)) {
|
||||
console.error(
|
||||
chalk.bold.red(
|
||||
`Model "${chalk.yellow(
|
||||
newModel,
|
||||
)}" is not available for provider "${chalk.yellow(
|
||||
currentProvider,
|
||||
)}".`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
onSelectHandler(allModels, unavailableModel);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(chalk.bold.red).toHaveBeenCalled();
|
||||
expect(chalk.yellow).toHaveBeenCalledWith(unavailableModel);
|
||||
expect(chalk.yellow).toHaveBeenCalledWith(currentProvider);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`[bold-red]Model "[yellow]${unavailableModel}[/yellow]" is not available for provider "[yellow]${currentProvider}[/yellow]".[/bold-red]`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not proceed with model change when model is unavailable", () => {
|
||||
const mockSetModel = vi.fn();
|
||||
const mockSetLastResponseId = vi.fn();
|
||||
const mockSaveConfig = vi.fn();
|
||||
const mockSetItems = vi.fn();
|
||||
const mockSetOverlayMode = vi.fn();
|
||||
|
||||
const onSelectHandler = vi.fn((allModels, newModel) => {
|
||||
if (!allModels?.includes(newModel)) {
|
||||
console.error(
|
||||
chalk.bold.red(
|
||||
`Model "${chalk.yellow(
|
||||
newModel,
|
||||
)}" is not available for provider "${chalk.yellow("openai")}".`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
mockSetModel(newModel);
|
||||
mockSetLastResponseId(null);
|
||||
mockSaveConfig({});
|
||||
mockSetItems((prev: Array<unknown>) => [...prev, {}]);
|
||||
mockSetOverlayMode("none");
|
||||
});
|
||||
|
||||
onSelectHandler(["gpt-4", "gpt-3.5-turbo"], "gpt-invalid");
|
||||
|
||||
expect(mockSetModel).not.toHaveBeenCalled();
|
||||
expect(mockSetLastResponseId).not.toHaveBeenCalled();
|
||||
expect(mockSaveConfig).not.toHaveBeenCalled();
|
||||
expect(mockSetItems).not.toHaveBeenCalled();
|
||||
expect(mockSetOverlayMode).not.toHaveBeenCalled();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import TerminalChatResponseItem from "../src/components/chat/terminal-chat-response-item.js";
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Component under test
|
||||
|
||||
// The ResponseItem type is complex and imported from the OpenAI SDK. To keep
|
||||
// this test lightweight we construct the minimal runtime objects we need and
|
||||
// cast them to `any` so that TypeScript is satisfied.
|
||||
|
||||
function userMessage(text: string) {
|
||||
return {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
}
|
||||
|
||||
function assistantMessage(text: string) {
|
||||
return {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("TerminalChatResponseItem", () => {
|
||||
it("renders a user message", () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<TerminalChatResponseItem
|
||||
item={userMessage("Hello world")}
|
||||
fileOpener={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("user");
|
||||
expect(frame).toContain("Hello world");
|
||||
});
|
||||
|
||||
it("renders an assistant message", () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<TerminalChatResponseItem
|
||||
item={assistantMessage("Sure thing")}
|
||||
fileOpener={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
// assistant messages are labelled "codex" in the UI
|
||||
expect(frame.toLowerCase()).toContain("codex");
|
||||
expect(frame).toContain("Sure thing");
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import TextBuffer from "../src/text-buffer.js";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// These tests ensure that the TextBuffer copy‑&‑paste logic keeps parity with
|
||||
// the Rust reference implementation (`textarea.rs`). When a multi‑line
|
||||
// string *without* a trailing newline is pasted at the beginning of a line,
|
||||
// the final pasted line should be merged with the text that originally
|
||||
// followed the caret – exactly how most editors behave.
|
||||
|
||||
function setupBuffer(): TextBuffer {
|
||||
return new TextBuffer("ab\ncd\nef");
|
||||
}
|
||||
|
||||
describe("TextBuffer – copy/paste multi‑line", () => {
|
||||
it("copies a multi‑line selection without the trailing newline", () => {
|
||||
const buf = setupBuffer();
|
||||
|
||||
// Select from (0,0) → (1,2) ["ab", "cd"]
|
||||
buf.startSelection(); // anchor at 0,0
|
||||
buf.move("down"); // 1,0
|
||||
buf.move("right");
|
||||
buf.move("right"); // 1,2
|
||||
|
||||
const copied = buf.copy();
|
||||
expect(copied).toBe("ab\ncd");
|
||||
});
|
||||
|
||||
it("pastes the multi‑line clipboard as separate lines (does not merge with following text)", () => {
|
||||
const buf = setupBuffer();
|
||||
|
||||
// Make the same selection and copy
|
||||
buf.startSelection();
|
||||
buf.move("down");
|
||||
buf.move("right");
|
||||
buf.move("right");
|
||||
buf.copy();
|
||||
|
||||
// Move caret to the start of the last line and paste
|
||||
buf.move("down");
|
||||
buf.move("home"); // (2,0)
|
||||
|
||||
const ok = buf.paste();
|
||||
expect(ok).toBe(true);
|
||||
|
||||
// Desired final buffer – behaviour should match the Rust reference:
|
||||
// the final pasted line is *merged* with the original text on the
|
||||
// insertion row.
|
||||
expect(buf.getLines()).toEqual(["ab", "cd", "ab", "cdef"]);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import TextBuffer from "../src/text-buffer.js";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("TextBuffer – newline normalisation", () => {
|
||||
it("insertStr should split on \r and \r\n sequences", () => {
|
||||
const buf = new TextBuffer("");
|
||||
|
||||
// Windows‑style CRLF
|
||||
buf.insertStr("ab\r\ncd\r\nef");
|
||||
|
||||
expect(buf.getLines()).toEqual(["ab", "cd", "ef"]);
|
||||
expect(buf.getCursor()).toEqual([2, 2]); // after 'f'
|
||||
});
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
import TextBuffer from "../src/text-buffer";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// The purpose of this test‑suite is NOT to make the implementation green today
|
||||
// – quite the opposite. We capture behaviours that are already covered by the
|
||||
// reference Rust implementation (textarea.rs) but are *still missing* from the
|
||||
// current TypeScript port. Every test is therefore marked with `.fails()` so
|
||||
// that the suite passes while the functionality is absent. When a particular
|
||||
// gap is closed the corresponding test will begin to succeed, causing Vitest to
|
||||
// raise an error (a *good* error) that reminds us to remove the `.fails` flag.
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Soft‑tab insertion */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("soft‑tab insertion (↹ => 4 spaces)", () => {
|
||||
it.fails(
|
||||
"inserts 4 spaces at caret position when hard‑tab mode is off",
|
||||
() => {
|
||||
const buf = new TextBuffer("");
|
||||
|
||||
// A literal "\t" character is treated as user pressing the Tab key. The
|
||||
// Rust version expands it to soft‑tabs by default.
|
||||
buf.insert("\t");
|
||||
|
||||
expect(buf.getText()).toBe(" ");
|
||||
expect(buf.getCursor()).toEqual([0, 4]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Undo / Redo – grouping & stack clearing */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("undo / redo – advanced behaviour", () => {
|
||||
it.fails(
|
||||
"typing a word character‑by‑character should undo in one step",
|
||||
() => {
|
||||
const buf = new TextBuffer("");
|
||||
|
||||
for (const ch of "hello") {
|
||||
buf.insert(ch);
|
||||
}
|
||||
|
||||
// One single undo should revert the *whole* word, leaving empty buffer.
|
||||
buf.undo();
|
||||
|
||||
expect(buf.getText()).toBe("");
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Selection – cut / delete selection */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("selection – cut/delete", () => {
|
||||
it.fails(
|
||||
"cut() removes the selected range and yanks it into clipboard",
|
||||
() => {
|
||||
const buf = new TextBuffer("foo bar baz");
|
||||
|
||||
// Select the middle word "bar"
|
||||
buf.move("wordRight"); // after "foo" + space => col 4
|
||||
buf.startSelection();
|
||||
buf.move("wordRight"); // after "bar" (col 8)
|
||||
// @ts-expect-error – method missing in current implementation
|
||||
buf.cut();
|
||||
|
||||
// Text should now read "foo baz" (two spaces collapsed only if impl trims)
|
||||
expect(buf.getText()).toBe("foo baz");
|
||||
|
||||
// Cursor should be at the start of the gap where text was removed
|
||||
expect(buf.getCursor()).toEqual([0, 4]);
|
||||
|
||||
// And clipboard/yank buffer should contain the deleted word
|
||||
// @ts-expect-error – clipboard getter not exposed yet
|
||||
expect(buf.getClipboard()).toBe("bar");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Word‑wise forward deletion (Ctrl+Delete) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("delete_next_word (Ctrl+Delete)", () => {
|
||||
it.fails("removes everything until the next word boundary", () => {
|
||||
const vp = { width: 80, height: 25 };
|
||||
const buf = new TextBuffer("hello world!! next");
|
||||
|
||||
// Place caret at start of line (0,0). One Ctrl+Delete should wipe the
|
||||
// word "hello" and the following space.
|
||||
buf.handleInput(undefined, { delete: true, ctrl: true }, vp);
|
||||
|
||||
expect(buf.getText()).toBe("world!! next");
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Configurable tab length */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("tab length configuration", () => {
|
||||
it.fails("inserts the configured number of spaces when tabLen=2", () => {
|
||||
// @ts-expect-error – constructor currently has no config object
|
||||
const buf = new TextBuffer("", { tabLen: 2 });
|
||||
|
||||
buf.insert("\t");
|
||||
|
||||
expect(buf.getText()).toBe(" "); // two spaces
|
||||
expect(buf.getCursor()).toEqual([0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Search subsystem */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("search / regex navigation", () => {
|
||||
it.fails("search_forward jumps to the next match", () => {
|
||||
const text = [
|
||||
"alpha beta gamma",
|
||||
"beta gamma alpha",
|
||||
"gamma alpha beta",
|
||||
].join("\n");
|
||||
|
||||
const buf = new TextBuffer(text);
|
||||
|
||||
// @ts-expect-error – method missing
|
||||
buf.setSearchPattern(/beta/);
|
||||
|
||||
// Cursor starts at 0,0. First search_forward should land on the first
|
||||
// occurrence (row 0, col 6)
|
||||
// @ts-expect-error – method missing
|
||||
buf.searchForward();
|
||||
|
||||
expect(buf.getCursor()).toEqual([0, 6]);
|
||||
|
||||
// Second invocation should wrap within viewport and find next occurrence
|
||||
// (row 1, col 0)
|
||||
// @ts-expect-error – method missing
|
||||
buf.searchForward();
|
||||
|
||||
expect(buf.getCursor()).toEqual([1, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Word‑wise navigation accuracy */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("wordLeft / wordRight – punctuation boundaries", () => {
|
||||
it.fails("wordLeft stops after punctuation like hyphen (-)", () => {
|
||||
const buf = new TextBuffer("hello-world");
|
||||
|
||||
// Place caret at end of line
|
||||
buf.move("end");
|
||||
|
||||
// Perform a single wordLeft – in Rust implementation this lands right
|
||||
// *after* the hyphen, i.e. between '-' and 'w' (column index 6).
|
||||
buf.move("wordLeft");
|
||||
|
||||
expect(buf.getCursor()).toEqual([0, 6]);
|
||||
});
|
||||
|
||||
it.fails(
|
||||
"wordRight stops after punctuation like underscore (_) which is not in JS boundary set",
|
||||
() => {
|
||||
const buf = new TextBuffer("foo_bar");
|
||||
|
||||
// From start, one wordRight should land right after the underscore (col 4)
|
||||
buf.move("wordRight");
|
||||
|
||||
expect(buf.getCursor()).toEqual([0, 4]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Word‑wise deletion (Ctrl+Backspace) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("word deletion shortcuts", () => {
|
||||
it.fails("Ctrl+Backspace deletes the previous word", () => {
|
||||
const vp = { width: 80, height: 25 };
|
||||
const buf = new TextBuffer("hello world");
|
||||
|
||||
// Place caret after the last character
|
||||
buf.move("end");
|
||||
|
||||
// Simulate Ctrl+Backspace (terminal usually sends backspace with ctrl flag)
|
||||
buf.handleInput(undefined, { backspace: true, ctrl: true }, vp);
|
||||
|
||||
// The whole word "world" (and the preceding space) should be removed,
|
||||
// leaving just "hello".
|
||||
expect(buf.getText()).toBe("hello");
|
||||
expect(buf.getCursor()).toEqual([0, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Paragraph navigation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("paragraph navigation", () => {
|
||||
it.fails("Jumping forward by paragraph stops after a blank line", () => {
|
||||
const text = [
|
||||
"first paragraph line 1",
|
||||
"first paragraph line 2",
|
||||
"", // blank line separates paragraphs
|
||||
"second paragraph line 1",
|
||||
].join("\n");
|
||||
|
||||
const buf = new TextBuffer(text);
|
||||
|
||||
// Start at very beginning
|
||||
// (No method exposed yet – once implemented we will call move("paragraphForward"))
|
||||
// For now we imitate the call; test will fail until the command exists.
|
||||
// @ts-expect-error – method not implemented yet
|
||||
buf.move("paragraphForward");
|
||||
|
||||
// Expect caret to land at start of the first line _after_ the blank one
|
||||
expect(buf.getCursor()).toEqual([3, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Independent scrolling */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("viewport scrolling independent of cursor", () => {
|
||||
it.fails("scrolls without moving the caret", () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line ${i}`);
|
||||
const buf = new TextBuffer(lines.join("\n"));
|
||||
const vp = { width: 10, height: 5 };
|
||||
|
||||
// Cursor stays at 0,0. We now ask the view to scroll down by one page.
|
||||
// @ts-expect-error – method not implemented yet
|
||||
buf.scroll("pageDown", vp);
|
||||
|
||||
// Cursor must remain at (0,0) even though viewport origin changed.
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
// The first visible line should now be "line 5".
|
||||
expect(buf.getVisibleLines(vp)[0]).toBe("line 5");
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import TextBuffer from "../src/text-buffer.js";
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("TextBuffer – word‑wise navigation & deletion", () => {
|
||||
test("wordRight moves to end‑of‑line when no further boundary", () => {
|
||||
const tb = new TextBuffer("hello");
|
||||
|
||||
// Move the caret inside the word (index 3)
|
||||
tb.move("right");
|
||||
tb.move("right");
|
||||
tb.move("right");
|
||||
|
||||
tb.move("wordRight");
|
||||
|
||||
const [, col] = tb.getCursor();
|
||||
expect(col).toBe(5); // end of the word / line
|
||||
});
|
||||
|
||||
test("Ctrl+Backspace on raw byte deletes previous word", () => {
|
||||
const tb = new TextBuffer("hello world");
|
||||
const vp = { height: 10, width: 80 } as const;
|
||||
|
||||
// Place caret at end
|
||||
tb.move("end");
|
||||
|
||||
// Simulate terminal sending DEL (0x7f) byte with ctrl modifier – Ink
|
||||
// usually does *not* set `key.backspace` in this path.
|
||||
tb.handleInput("\x7f", { ctrl: true }, vp);
|
||||
|
||||
expect(tb.getText()).toBe("hello ");
|
||||
});
|
||||
|
||||
test("Option/Alt+Backspace deletes previous word", () => {
|
||||
const tb = new TextBuffer("foo bar baz");
|
||||
const vp = { height: 10, width: 80 } as const;
|
||||
|
||||
// caret at end
|
||||
tb.move("end");
|
||||
|
||||
// Simulate Option+Backspace (alt): Ink sets key.backspace = true, key.alt = true (no raw byte)
|
||||
tb.handleInput(undefined, { backspace: true, alt: true }, vp);
|
||||
|
||||
expect(tb.getText()).toBe("foo bar ");
|
||||
});
|
||||
|
||||
test("Option/Alt+Delete deletes previous word (matches shells)", () => {
|
||||
const tb = new TextBuffer("foo bar baz");
|
||||
const vp = { height: 10, width: 80 } as const;
|
||||
|
||||
// Place caret at end so we can test backward deletion.
|
||||
tb.move("end");
|
||||
|
||||
// Simulate Option+Delete (parsed as alt-modified Delete on some terminals)
|
||||
tb.handleInput(undefined, { delete: true, alt: true }, vp);
|
||||
|
||||
expect(tb.getText()).toBe("foo bar ");
|
||||
});
|
||||
|
||||
test("wordLeft eventually reaches column 0", () => {
|
||||
const tb = new TextBuffer("hello world");
|
||||
|
||||
// Move to end of line first
|
||||
tb.move("end");
|
||||
|
||||
// two wordLefts should land at start of line
|
||||
tb.move("wordLeft");
|
||||
tb.move("wordLeft");
|
||||
|
||||
const [, col] = tb.getCursor();
|
||||
expect(col).toBe(0);
|
||||
});
|
||||
|
||||
test("wordRight jumps over a delimiter into the next word", () => {
|
||||
const tb = new TextBuffer("hello world");
|
||||
|
||||
tb.move("wordRight"); // from start – should land after "hello" (between space & w)
|
||||
let [, col] = tb.getCursor();
|
||||
expect(col).toBe(5);
|
||||
|
||||
// Next wordRight should move to end of line (after "world")
|
||||
tb.move("wordRight");
|
||||
[, col] = tb.getCursor();
|
||||
expect(col).toBe(11);
|
||||
});
|
||||
|
||||
test("deleteWordLeft after trailing space only deletes the last word, not the whole line", () => {
|
||||
const tb = new TextBuffer("I want you to refactor my view ");
|
||||
tb.move("end"); // Place caret after the space
|
||||
tb.deleteWordLeft();
|
||||
expect(tb.getText()).toBe("I want you to refactor my ");
|
||||
const [, col] = tb.getCursor();
|
||||
expect(col).toBe("I want you to refactor my ".length);
|
||||
});
|
||||
|
||||
test("deleteWordLeft removes the previous word and positions the caret correctly", () => {
|
||||
const tb = new TextBuffer("hello world");
|
||||
|
||||
// Place caret at end of line
|
||||
tb.move("end");
|
||||
|
||||
// Act
|
||||
tb.deleteWordLeft();
|
||||
|
||||
expect(tb.getText()).toBe("hello ");
|
||||
const [, col] = tb.getCursor();
|
||||
expect(col).toBe(6); // after the space
|
||||
});
|
||||
|
||||
test("deleteWordRight removes the following word", () => {
|
||||
const tb = new TextBuffer("hello world");
|
||||
|
||||
// Move caret to start of "world"
|
||||
tb.move("wordRight"); // caret after "hello"
|
||||
tb.move("right"); // skip the space, now at index 6 (start of world)
|
||||
|
||||
// Act
|
||||
tb.deleteWordRight();
|
||||
|
||||
expect(tb.getText()).toBe("hello ");
|
||||
const [, col] = tb.getCursor();
|
||||
expect(col).toBe(6);
|
||||
});
|
||||
|
||||
test("Shift+Option/Alt+Delete deletes next word", () => {
|
||||
const tb = new TextBuffer("foo bar baz");
|
||||
const vp = { height: 10, width: 80 } as const;
|
||||
|
||||
// Move caret between first and second word (after space)
|
||||
tb.move("wordRight"); // after foo
|
||||
tb.move("right"); // skip space -> start of bar
|
||||
|
||||
// Shift+Option+Delete should now remove "bar "
|
||||
tb.handleInput(undefined, { delete: true, alt: true, shift: true }, vp);
|
||||
|
||||
expect(tb.getText()).toBe("foo baz");
|
||||
});
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
import TextBuffer from "../src/text-buffer";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("TextBuffer – basic editing parity with Rust suite", () => {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* insert_char */
|
||||
/* ------------------------------------------------------------------ */
|
||||
it("insert_char / printable (single line)", () => {
|
||||
// (col, char, expectedLine)
|
||||
const cases: Array<[number, string, string]> = [
|
||||
[0, "x", "xab"],
|
||||
[1, "x", "axb"],
|
||||
[2, "x", "abx"],
|
||||
[1, "あ", "aあb"],
|
||||
];
|
||||
|
||||
for (const [col, ch, want] of cases) {
|
||||
const buf = new TextBuffer("ab");
|
||||
buf.move("end"); // go to col 2
|
||||
while (buf.getCursor()[1] > col) {
|
||||
buf.move("left");
|
||||
}
|
||||
buf.insert(ch);
|
||||
expect(buf.getText()).toBe(want);
|
||||
expect(buf.getCursor()).toEqual([0, col + 1]);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* insert_char – newline support */
|
||||
/* ------------------------------------------------------------------ */
|
||||
it("insert_char with a newline should split the line", () => {
|
||||
const buf = new TextBuffer("ab");
|
||||
// jump to end of first (and only) line
|
||||
buf.move("end");
|
||||
// Insert a raw \n character – the Rust implementation splits the line
|
||||
buf.insert("\n");
|
||||
|
||||
// We expect the text to be split into two separate lines
|
||||
expect(buf.getLines()).toEqual(["ab", ""]);
|
||||
expect(buf.getCursor()).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* insert_str helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
it("insert_str should insert multi‑line strings", () => {
|
||||
const initial = ["ab", "cd", "ef"].join("\n");
|
||||
const buf = new TextBuffer(initial);
|
||||
|
||||
// place cursor at (row:0, col:0)
|
||||
// No move needed – cursor starts at 0,0
|
||||
|
||||
buf.insertStr("x\ny");
|
||||
|
||||
const wantLines = ["x", "yab", "cd", "ef"];
|
||||
expect(buf.getLines()).toEqual(wantLines);
|
||||
expect(buf.getCursor()).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Undo / Redo */
|
||||
/* ------------------------------------------------------------------ */
|
||||
it("undo / redo history should revert edits", () => {
|
||||
const buf = new TextBuffer("hello");
|
||||
buf.move("end");
|
||||
buf.insert("!"); // text becomes "hello!"
|
||||
|
||||
expect(buf.undo()).toBe(true);
|
||||
expect(buf.getText()).toBe("hello");
|
||||
|
||||
expect(buf.redo()).toBe(true);
|
||||
expect(buf.getText()).toBe("hello!");
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Selection model */
|
||||
/* ------------------------------------------------------------------ */
|
||||
it("copy & paste should operate on current selection", () => {
|
||||
const buf = new TextBuffer("hello world");
|
||||
buf.startSelection();
|
||||
// Select the word "hello"
|
||||
buf.move("right"); // h
|
||||
buf.move("right"); // e
|
||||
buf.move("right"); // l
|
||||
buf.move("right"); // l
|
||||
buf.move("right"); // o
|
||||
buf.endSelection();
|
||||
buf.copy();
|
||||
|
||||
// Move to end and paste
|
||||
buf.move("end");
|
||||
// add one space before pasting copied word
|
||||
buf.insert(" ");
|
||||
buf.paste();
|
||||
|
||||
expect(buf.getText()).toBe("hello world hello");
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Backspace behaviour */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("backspace", () => {
|
||||
it("deletes the character to the *left* of the caret within a line", () => {
|
||||
const buf = new TextBuffer("abc");
|
||||
|
||||
// Move caret after the second character ( index 2 => after 'b' )
|
||||
buf.move("right"); // -> a|bc (col 1)
|
||||
buf.move("right"); // -> ab|c (col 2)
|
||||
|
||||
buf.backspace();
|
||||
|
||||
expect(buf.getLines()).toEqual(["ac"]);
|
||||
expect(buf.getCursor()).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("merges with the previous line when invoked at column 0", () => {
|
||||
const buf = new TextBuffer(["ab", "cd"].join("\n"));
|
||||
|
||||
// Place caret at the beginning of second line
|
||||
buf.move("down"); // row = 1, col = 0
|
||||
|
||||
buf.backspace();
|
||||
|
||||
expect(buf.getLines()).toEqual(["abcd"]);
|
||||
expect(buf.getCursor()).toEqual([0, 2]); // after 'b'
|
||||
});
|
||||
|
||||
it("is a no-op at the very beginning of the buffer", () => {
|
||||
const buf = new TextBuffer("ab");
|
||||
buf.backspace(); // caret starts at (0,0)
|
||||
|
||||
expect(buf.getLines()).toEqual(["ab"]);
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor initialization", () => {
|
||||
it("initializes cursor to (0,0) by default", () => {
|
||||
const buf = new TextBuffer("hello\nworld");
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("sets cursor to valid position within line", () => {
|
||||
const buf = new TextBuffer("hello", 2);
|
||||
expect(buf.getCursor()).toEqual([0, 2]); // cursor at 'l'
|
||||
});
|
||||
|
||||
it("sets cursor to end of line", () => {
|
||||
const buf = new TextBuffer("hello", 5);
|
||||
expect(buf.getCursor()).toEqual([0, 5]); // cursor after 'o'
|
||||
});
|
||||
|
||||
it("sets cursor across multiple lines", () => {
|
||||
const buf = new TextBuffer("hello\nworld", 7);
|
||||
expect(buf.getCursor()).toEqual([1, 1]); // cursor at 'o' in 'world'
|
||||
});
|
||||
|
||||
it("defaults to position 0 for invalid index", () => {
|
||||
const buf = new TextBuffer("hello", 999);
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Vertical cursor movement – we should preserve the preferred column */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("up / down navigation keeps the preferred column", () => {
|
||||
it("restores horizontal position when moving across shorter lines", () => {
|
||||
// Three lines: long / short / long
|
||||
const lines = ["abcdef", "x", "abcdefg"].join("\n");
|
||||
const buf = new TextBuffer(lines);
|
||||
|
||||
// Place caret after the 5th char in first line (col = 5)
|
||||
buf.move("end"); // col 6 (after 'f')
|
||||
buf.move("left"); // col 5 (between 'e' and 'f')
|
||||
|
||||
// Move down twice – through a short line and back to a long one
|
||||
buf.move("down"); // should land on (1, 1) due to clamp
|
||||
buf.move("down"); // desired: (2, 5)
|
||||
|
||||
expect(buf.getCursor()).toEqual([2, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Left / Right arrow navigation across Unicode surrogate pairs */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("left / right navigation", () => {
|
||||
it("should treat multi‑code‑unit emoji as a single character", () => {
|
||||
// '🐶' is a surrogate‑pair (length 2) but one user‑perceived char.
|
||||
const buf = new TextBuffer("🐶a");
|
||||
|
||||
// Move caret once to the right – logically past the emoji.
|
||||
buf.move("right");
|
||||
|
||||
// Insert another printable character
|
||||
buf.insert("x");
|
||||
|
||||
// We expect the emoji to stay intact and the text to be 🐶xa
|
||||
expect(buf.getLines()).toEqual(["🐶xa"]);
|
||||
// Cursor should be after the inserted char (two visible columns along)
|
||||
expect(buf.getCursor()).toEqual([0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* HandleInput – raw DEL bytes should map to backspace */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
it("handleInput should treat \x7f input as backspace", () => {
|
||||
const buf = new TextBuffer("");
|
||||
const vp = { width: 80, height: 25 };
|
||||
|
||||
// Type "hello" via printable input path
|
||||
for (const ch of "hello") {
|
||||
buf.handleInput(ch, {}, vp);
|
||||
}
|
||||
|
||||
// Two DEL bytes – terminal's backspace
|
||||
buf.handleInput("\x7f", {}, vp);
|
||||
buf.handleInput("\x7f", {}, vp);
|
||||
|
||||
expect(buf.getText()).toBe("hel");
|
||||
expect(buf.getCursor()).toEqual([0, 3]);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* HandleInput – `key.delete` should ALSO behave as backspace */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
it("handleInput should treat key.delete as backspace", () => {
|
||||
const buf = new TextBuffer("");
|
||||
const vp = { width: 80, height: 25 };
|
||||
|
||||
for (const ch of "hello") {
|
||||
buf.handleInput(ch, {}, vp);
|
||||
}
|
||||
|
||||
// Simulate the Delete (Mac backspace) key three times
|
||||
buf.handleInput(undefined, { delete: true }, vp);
|
||||
buf.handleInput(undefined, { delete: true }, vp);
|
||||
buf.handleInput(undefined, { delete: true }, vp);
|
||||
|
||||
expect(buf.getText()).toBe("he");
|
||||
expect(buf.getCursor()).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Cursor positioning semantics */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("cursor movement & backspace semantics", () => {
|
||||
it("typing should leave cursor after the last inserted character", () => {
|
||||
const vp = { width: 80, height: 25 };
|
||||
const buf = new TextBuffer("");
|
||||
|
||||
buf.handleInput("h", {}, vp);
|
||||
expect(buf.getCursor()).toEqual([0, 1]);
|
||||
|
||||
for (const ch of "ello") {
|
||||
buf.handleInput(ch, {}, vp);
|
||||
}
|
||||
expect(buf.getCursor()).toEqual([0, 5]); // after 'o'
|
||||
});
|
||||
|
||||
it("arrow‑left moves the caret to *between* characters (highlight next)", () => {
|
||||
const vp = { width: 80, height: 25 };
|
||||
const buf = new TextBuffer("");
|
||||
for (const ch of "bar") {
|
||||
buf.handleInput(ch, {}, vp);
|
||||
} // cursor at col 3
|
||||
|
||||
buf.move("left"); // col 2 (right before 'r')
|
||||
buf.move("left"); // col 1 (right before 'a')
|
||||
|
||||
expect(buf.getCursor()).toEqual([0, 1]);
|
||||
// Character to the RIGHT of caret should be 'a'
|
||||
const charRight = [...buf.getLines()[0]!][buf.getCursor()[1]];
|
||||
expect(charRight).toBe("a");
|
||||
|
||||
// Backspace should delete the char to the *left* (i.e. 'b'), leaving "ar"
|
||||
buf.backspace();
|
||||
expect(buf.getLines()[0]).toBe("ar");
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
// Mock OpenAI to avoid API key requirement
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
}
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
|
||||
});
|
||||
|
||||
// Stub the logger to avoid file‑system side effects during tests
|
||||
vi.mock("../src/utils/logger/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Import AgentLoop after mocking dependencies
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("Token streaming performance", () => {
|
||||
// Mock callback for collecting tokens and their timestamps
|
||||
const mockOnItem = vi.fn();
|
||||
let startTime: number;
|
||||
const tokenTimestamps: Array<number> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
startTime = Date.now();
|
||||
tokenTimestamps.length = 0;
|
||||
|
||||
// Set up the mockOnItem to record timestamps when tokens are received
|
||||
mockOnItem.mockImplementation(() => {
|
||||
tokenTimestamps.push(Date.now() - startTime);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("processes tokens with minimal delay", async () => {
|
||||
// Create a minimal AgentLoop instance
|
||||
const agentLoop = new AgentLoop({
|
||||
model: "gpt-4",
|
||||
approvalPolicy: "auto-edit",
|
||||
additionalWritableRoots: [],
|
||||
onItem: mockOnItem,
|
||||
onLoading: vi.fn(),
|
||||
getCommandConfirmation: vi.fn().mockResolvedValue({ review: "approve" }),
|
||||
onLastResponseId: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock a stream of 100 tokens
|
||||
const mockItems = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) =>
|
||||
({
|
||||
id: `token-${i}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: `Token ${i}` }],
|
||||
status: "completed",
|
||||
}) as ResponseItem,
|
||||
);
|
||||
|
||||
// Call run with some input
|
||||
const runPromise = agentLoop.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Test message" }],
|
||||
},
|
||||
]);
|
||||
|
||||
// Instead of trying to access private methods, just call onItem directly
|
||||
// This still tests the timing and processing of tokens
|
||||
mockItems.forEach((item) => {
|
||||
agentLoop["onItem"](item);
|
||||
// Advance the timer slightly to simulate small processing time
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
// Advance time to complete any pending operations
|
||||
vi.runAllTimers();
|
||||
await runPromise;
|
||||
|
||||
// Verify that tokens were processed (note that we're using a spy so exact count may vary
|
||||
// due to other test setup and runtime internal calls)
|
||||
expect(mockOnItem).toHaveBeenCalled();
|
||||
|
||||
// Calculate performance metrics
|
||||
const intervals = tokenTimestamps
|
||||
.slice(1)
|
||||
.map((t, i) => t - (tokenTimestamps[i] || 0));
|
||||
const avgDelay =
|
||||
intervals.length > 0
|
||||
? intervals.reduce((sum, i) => sum + i, 0) / intervals.length
|
||||
: 0;
|
||||
|
||||
// With queueMicrotask, the delay should be minimal
|
||||
// We're expecting the average delay to be very small (less than 2ms in this simulated environment)
|
||||
expect(avgDelay).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Regression test – ensure that the TypeaheadOverlay passes the *complete*
|
||||
* list of items down to <SelectInput>. This guarantees that users can scroll
|
||||
* through the full set instead of being limited to the hard‑coded "limit"
|
||||
* slice that is only meant to control how many rows are visible at once.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock <select-input> so we can capture the props that TypeaheadOverlay
|
||||
// forwards without rendering the real component (which would require a full
|
||||
// Ink TTY environment).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let receivedItems: Array<{ label: string; value: string }> | null = null;
|
||||
vi.mock("../src/components/select-input/select-input.js", () => {
|
||||
return {
|
||||
default: (props: any) => {
|
||||
receivedItems = props.items;
|
||||
return null; // Do not render anything – we only care about the props
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Ink's <TextInput> toggles raw‑mode which calls .ref() / .unref() on stdin.
|
||||
// The test environment's mock streams don't implement those methods, so we
|
||||
// polyfill them to no-ops on the prototype *before* the component tree mounts.
|
||||
import { EventEmitter } from "node:events";
|
||||
if (!(EventEmitter.prototype as any).ref) {
|
||||
(EventEmitter.prototype as any).ref = () => {};
|
||||
(EventEmitter.prototype as any).unref = () => {};
|
||||
}
|
||||
|
||||
import type { TypeaheadItem } from "../src/components/typeahead-overlay.js";
|
||||
import TypeaheadOverlay from "../src/components/typeahead-overlay.js";
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
|
||||
describe("TypeaheadOverlay – scrolling capability", () => {
|
||||
it("passes the full item list to <SelectInput> so users can scroll beyond the visible limit", async () => {
|
||||
const ITEMS: Array<TypeaheadItem> = Array.from({ length: 20 }, (_, i) => ({
|
||||
label: `model-${i + 1}`,
|
||||
value: `model-${i + 1}`,
|
||||
}));
|
||||
|
||||
// Sanity – reset capture before rendering
|
||||
receivedItems = null;
|
||||
|
||||
const { flush, cleanup } = renderTui(
|
||||
React.createElement(TypeaheadOverlay, {
|
||||
title: "Test",
|
||||
initialItems: ITEMS,
|
||||
limit: 5, // visible rows – should *not* limit the underlying list
|
||||
onSelect: () => {},
|
||||
onExit: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
await flush(); // allow first render to complete
|
||||
|
||||
expect(receivedItems).not.toBeNull();
|
||||
expect((receivedItems ?? []).length).toBe(ITEMS.length);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import { render } from "ink-testing-library";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
/**
|
||||
* Render an Ink component for testing.
|
||||
*
|
||||
* Returns the full testing‑library utils plus `lastFrameStripped()` which
|
||||
* yields the latest rendered frame with ANSI escape codes removed so that
|
||||
* assertions can be colour‑agnostic.
|
||||
*/
|
||||
export function renderTui(ui: React.ReactElement): any {
|
||||
const utils = render(ui);
|
||||
|
||||
const lastFrameStripped = () => stripAnsi(utils.lastFrame() || "");
|
||||
|
||||
// A tiny helper that waits for Ink's internal promises / timers to settle
|
||||
// so the next `lastFrame()` call reflects the latest UI state.
|
||||
const flush = async () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
return {
|
||||
...utils,
|
||||
lastFrameStripped,
|
||||
flush,
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
/**
|
||||
* Verifies that ~/.codex.env is parsed (lowest‑priority) when present.
|
||||
*/
|
||||
|
||||
describe("user‑wide ~/.codex.env support", () => {
|
||||
const ORIGINAL_HOME = process.env["HOME"];
|
||||
const ORIGINAL_API_KEY = process.env["OPENAI_API_KEY"];
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create an isolated fake $HOME directory.
|
||||
tempHome = mkdtempSync(join(tmpdir(), "codex-home-"));
|
||||
process.env["HOME"] = tempHome;
|
||||
|
||||
// Ensure the env var is unset so that the file value is picked up.
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
|
||||
// Write ~/.codex.env with a dummy key.
|
||||
writeFileSync(
|
||||
join(tempHome, ".codex.env"),
|
||||
"OPENAI_API_KEY=my-home-key\n",
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup temp directory.
|
||||
try {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Restore original env.
|
||||
if (ORIGINAL_HOME !== undefined) {
|
||||
process.env["HOME"] = ORIGINAL_HOME;
|
||||
} else {
|
||||
delete process.env["HOME"];
|
||||
}
|
||||
|
||||
if (ORIGINAL_API_KEY !== undefined) {
|
||||
process.env["OPENAI_API_KEY"] = ORIGINAL_API_KEY;
|
||||
} else {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
}
|
||||
});
|
||||
|
||||
it("loads the API key from ~/.codex.env when not set elsewhere", async () => {
|
||||
// Import the config module AFTER setting up the fake env.
|
||||
const { getApiKey } = await import("../src/utils/config.js");
|
||||
|
||||
expect(getApiKey("openai")).toBe("my-home-key");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user