diff --git a/banner.jpg b/banner.jpg index a5e96b6..27c4339 100644 Binary files a/banner.jpg and b/banner.jpg differ diff --git a/cli/src/main.rs b/cli/src/main.rs index 5cf9b42..a80cd5e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1810,7 +1810,7 @@ fn reject_flags_in_prompt_text(tokens: &[String]) -> Result<(), String> { /// Config keys a `prompt --config KEY=VALUE` run may override. Anything else is /// rejected up front rather than silently ignored. -const PROMPT_CONFIG_KEYS: &[&str] = &["frontal_base_url", "skip_git_repo_check"]; +const PROMPT_CONFIG_KEYS: &[&str] = &["frontal_base_url", "skip_git_repo_check", "output_schema"]; /// Parses `prompt [-p TEXT] [--stream] [--resume REF] [--config KEY=VALUE] [TEXT...]`. fn parse_prompt_subcommand_args( diff --git a/sdks/rust/README.md b/sdks/rust/README.md index 77f13b0..850b65f 100644 --- a/sdks/rust/README.md +++ b/sdks/rust/README.md @@ -18,12 +18,12 @@ Requires the `frontal-code` CLI on `PATH` (or provide a custom `command`). ## Quickstart ```rust -use frontal_code_sdk::{Frontal Code, ThreadInput, ThreadOptions, ThreadRunOptions}; +use frontal_code_sdk::{FrontalCode, ThreadInput, ThreadOptions, ThreadRunOptions}; #[tokio::main] async fn main() -> Result<(), Box> { - let frontal-code = Frontal Code::new(Default::default()); - let thread = frontal-code.start_thread(ThreadOptions::default()); + let sdk = FrontalCode::new(Default::default()); + let thread = sdk.start_thread(ThreadOptions::default()); let turn = thread .run(&ThreadInput::Text("Diagnose the test failure".into()), &ThreadRunOptions::default()) .await?; @@ -34,17 +34,23 @@ async fn main() -> Result<(), Box> { ## Features -- `Frontal Code::start_thread` / `Frontal Code::resume_thread` +- `FrontalCode::start_thread` / `FrontalCode::resume_thread` - `Thread::run` (buffered `TurnResult`) and `Thread::run_streamed` (event stream) -- Structured input entries (`text`, `local_image`) +- Structured input entries (`text`; `local_image` is accepted in the protocol but + the CLI does not yet forward images) - `--config` overrides flattened to dotted TOML literals (incl. `baseUrl` → - `frontal_base_url`) + `frontal_base_url`, `output_schema`) - Multi-turn continuation via `--resume` +- Permission control: `--permission-mode`, `--dangerously-skip-permissions` +- Tool restriction: `--allowed-tools` (repeatable) ## Testing The SDK's tests are hermetic: they spawn a small mock `frontal-code` script instead of -the real binary, so no API key or network is required. +the real binary, so no API key or network is required. Authentication is handled by the +`frontal-code` CLI through provider-specific environment variables (e.g. +`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `FRONTAL_API_KEY`) that you set in your shell; +this SDK does not inject any environment variables of its own. ```bash cargo test -p frontal-code-sdk diff --git a/sdks/rust/docs/ARCHITECTURE.md b/sdks/rust/docs/ARCHITECTURE.md index 56df8dc..30f7fe6 100644 --- a/sdks/rust/docs/ARCHITECTURE.md +++ b/sdks/rust/docs/ARCHITECTURE.md @@ -104,11 +104,11 @@ All config overrides are flattened to dotted TOML and passed as repeated Tests are hermetic - they spawn a mock `frontal-code` script instead of the real CLI: ```rust -// tests/mock_frontal-code.sh - emits predetermined JSONL events +// path/to/frontal-code - emits predetermined JSONL events #[tokio::test] async fn test_thread_run() { let frontal-code = Frontal Code::new(FrontalCodeConfig { - command: Some("tests/mock_frontal-code.sh".into()), + command: Some("path/to/frontal-code".into()), ..Default::default() }); // ... test assertions diff --git a/sdks/rust/docs/EXAMPLES.md b/sdks/rust/docs/EXAMPLES.md index 743a7ad..e9b36f2 100644 --- a/sdks/rust/docs/EXAMPLES.md +++ b/sdks/rust/docs/EXAMPLES.md @@ -257,7 +257,7 @@ mod tests { #[tokio::test] async fn test_basic_thread() { let frontal-code = Frontal Code::new(FrontalCodeConfig { - command: Some("tests/mock_frontal-code.sh".into()), + command: Some("path/to/frontal-code".into()), env: Some([("REQUEST_TYPE".into(), "basic".into())].into()), ..Default::default() }); diff --git a/sdks/rust/docs/TESTING.md b/sdks/rust/docs/TESTING.md index 6362696..112959a 100644 --- a/sdks/rust/docs/TESTING.md +++ b/sdks/rust/docs/TESTING.md @@ -2,144 +2,45 @@ ## Test Architecture -Tests are fully hermetic - they do **not** require: +Most tests are hermetic. They do **not** require: - Network access -- API keys (`CODEX_API_KEY`) -- Real `frontal-code` CLI binary +- Provider API keys +- The real `frontal-code` CLI -Instead, tests use a mock CLI script (`tests/mock_frontal-code.sh`) that emits -predefined JSONL events over stdout. +`tests/common/mod.rs` writes a small fake `frontal-code` executable at runtime. The fake emits predefined JSONL events and records the arguments it was called with, so the tests can assert on the exact flags the SDK passes. + +The end-to-end suite (`tests/e2e_test.rs`) is the exception. It runs the real CLI against a live model and is skipped unless `FRONTAL_CODE_E2E_BIN` is set. ## Running Tests ```bash -# Run all tests -cargo test --package frontal-code-sdk - -# Run with output -cargo test --package frontal-code-sdk -- --nocapture +# Hermetic tests (no CLI, no network) +cargo test -p frontal-code-sdk -# Run specific test -cargo test --package frontal-code-sdk test_thread_run -- --nocapture +# End-to-end against a local Ollama model (see below) +FRONTAL_CODE_E2E_BIN=$PWD/target/debug/code \ + cargo test --manifest-path sdks/rust/Cargo.toml --test e2e_test ``` ## Test Structure ``` tests/ -├── mock_frontal-code.sh # Mock CLI - emits JSONL events -├── test_basic.rs # Basic thread run tests -├── test_streaming.rs # Stream event tests -├── test_structured.rs # Structured output / JSON Schema tests -├── test_resume.rs # Thread resume tests -├── test_config.rs # Config override tests -└── test_errors.rs # Error handling tests -``` - -## Mock CLI Protocol - -The mock CLI (`tests/mock_frontal-code.sh`) reads stdin for the request payload -and writes JSONL events to stdout. It understands these request fields: - -```json -{ - "input": "user message", - "session_id": "optional-resume-id", - "config": { "key": "value" }, - "output_schema": { ... } -} -``` - -The mock responds with events matching the real CLI format: - -```jsonl -{"type":"item_completed","item":{"id":"msg-1","type":"message","content":"Hello!"}} -{"type":"turn_completed","result":{"final_response":"Hello!","items":[],"usage":{}}} +├── common/mod.rs # Shared fake CLI and helpers +├── integration_test.rs # Thread run, streaming, resume, and flag mapping (mocked) +├── config_test.rs # --config overrides and env handling (mocked) +└── e2e_test.rs # Real CLI against a live model (gated) ``` -## Writing New Tests +## End-to-End Suite -### 1. Add test events to `mock_frontal-code.sh` - -```bash -case "$REQUEST_TYPE" in - "my_new_scenario") - cat <<'EOF' - {"type":"item_completed","item":{"id":"msg-1","type":"message","content":"Custom response"}} - {"type":"turn_completed","result":{"final_response":"Custom response","items":[],"usage":{}}} -EOF - ;; -esac -``` - -### 2. Create test case - -```rust -#[tokio::test] -async fn test_my_new_scenario() { - let frontal-code = Frontal Code::new(FrontalCodeConfig { - command: Some("tests/mock_frontal-code.sh".into()), - env: Some([("REQUEST_TYPE".into(), "my_new_scenario".into())].into()), - ..Default::default() - }); - - let thread = frontal-code.start_thread(ThreadOptions::default()).unwrap(); - let result = thread.run(&ThreadInput::Text("test".into()), &ThreadRunOptions::default()).unwrap(); - - assert_eq!(result.final_response, "Custom response"); -} -``` - -## Test Utilities - -### `TestFrontalCodeBuilder` (in `tests/common.rs`) - -```rust -let frontal-code = TestFrontalCodeBuilder::new() - .with_mock_response("my_scenario") - .with_env("CUSTOM_VAR", "value") - .build(); -``` - -### Event Assertions - -```rust -use frontal_code_sdk::test_utils::{assert_event_seq, assert_final_response}; - -let mut stream = thread.run_streamed(&input, &options).unwrap(); -assert_event_seq!(&mut stream, ItemCompleted, TurnCompleted); -let result = assert_final_response(stream).await; -``` - -## Continuous Integration - -Tests run in GitHub Actions on every PR: - -```yaml -# .github/workflows/rust-sdk.yml -- uses: actions-rs/cargo@v1 - with: - command: test - args: --package frontal-code-sdk --workspace -``` - -## Coverage - -```bash -cargo llvm-cov --package frontal-code-sdk --html -``` - -## End-to-end suite (real CLI) - -`tests/e2e_test.rs` runs the real `code` binary against a live model. It is skipped unless `FRONTAL_CODE_E2E_BIN` is set. The default provider is a local Ollama server: +The default provider is a local Ollama server, so no key is needed: ```bash cargo build -p cli ollama pull qwen2.5:1.5b -FRONTAL_CODE_E2E_BIN="$PWD/target/debug/code" \ +FRONTAL_CODE_E2E_BIN=$PWD/target/debug/code \ cargo test --manifest-path sdks/rust/Cargo.toml --test e2e_test ``` -Set `FRONTAL_CODE_E2E_PROVIDER=openai` with `OPENAI_API_KEY` (and `OPENAI_BASE_URL`) to run against a cloud gateway instead. - -A skipped run reports as passing, so check that the variables are set before you read a green result. +Set `FRONTAL_CODE_E2E_PROVIDER=openai` with `OPENAI_API_KEY` (and `OPENAI_BASE_URL`) to run against a cloud gateway instead. A skipped run reports as passing, so check that `FRONTAL_CODE_E2E_BIN` is set before you read a green result. diff --git a/sdks/rust/src/code.rs b/sdks/rust/src/code.rs index 639a768..4b1ad44 100644 --- a/sdks/rust/src/code.rs +++ b/sdks/rust/src/code.rs @@ -8,8 +8,6 @@ use std::sync::Arc; use crate::protocol::{FrontalCodeOptions, ThreadOptions}; use crate::thread::Thread; -const REQUIRED_ENV_VARS: &[&str] = &["CODEX_API_KEY"]; - /// The two names every install channel provides for the same CLI. const COMMAND_NAMES: [&str; 2] = ["frontal-code", "code"]; @@ -71,8 +69,10 @@ impl FrontalCode { } /// Build the environment for a spawned CLI. Starts from the ambient - /// environment, applies the user-provided `env`, then injects any required - /// variables (such as `CODEX_API_KEY`) if still missing. + /// environment, applies the user-provided `env`. + /// The `frontal-code` CLI authenticates via provider-specific environment + /// variables (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) which the caller + /// is responsible for setting; this SDK does not inject any of its own. pub fn build_env(&self) -> HashMap { let mut env: HashMap = std::env::vars().collect(); if let Some(user_env) = &self.options.env { @@ -80,13 +80,6 @@ impl FrontalCode { env.insert(key.clone(), value.clone()); } } - for var in REQUIRED_ENV_VARS { - if !env.contains_key(*var) { - if let Ok(value) = std::env::var(var) { - env.insert((*var).to_string(), value); - } - } - } env } } diff --git a/sdks/rust/src/protocol.rs b/sdks/rust/src/protocol.rs index 0cd86e9..d6cb28d 100644 --- a/sdks/rust/src/protocol.rs +++ b/sdks/rust/src/protocol.rs @@ -98,6 +98,10 @@ pub struct ThreadOptions { pub model: Option, /// Permission mode override. pub permission_mode: Option, + /// Skip all permission checks (maps to `--dangerously-skip-permissions`). + pub dangerously_skip_permissions: bool, + /// Restrict enabled tools (maps to `--allowed-tools`, repeatable). + pub allowed_tools: Option>, /// Thread-level CLI `--config` overrides (precedence over global). pub config: Option, } diff --git a/sdks/rust/src/spawn.rs b/sdks/rust/src/spawn.rs index 1be28c4..49100f9 100644 --- a/sdks/rust/src/spawn.rs +++ b/sdks/rust/src/spawn.rs @@ -182,7 +182,14 @@ pub async fn run_turn_buffered( usage = u.clone(); session_id = sid.clone(); } - _ => {} + FrontalCodeEvent::TurnFailed { error } => { + let stderr = stderr_buffer.lock().await.clone(); + return Err(FrontalCodeError::Exit { + code: None, + stderr: format!("frontal-code turn failed: {error}\n{stderr}"), + }); + } + FrontalCodeEvent::TurnStarted => {} } } diff --git a/sdks/rust/src/thread.rs b/sdks/rust/src/thread.rs index c795da1..563389b 100644 --- a/sdks/rust/src/thread.rs +++ b/sdks/rust/src/thread.rs @@ -89,10 +89,10 @@ impl Thread { let (prompt, images) = input.to_prompt_and_images(); let mut args = vec!["prompt".to_string(), "-p".to_string(), prompt]; - for image in &images { - args.push("--image".to_string()); - args.push(image.clone()); - } + // `local_image` entries are kept in the protocol for forward compatibility, + // but the `frontal-code` CLI does not yet accept `--image`, so only text + // entries are concatenated into the prompt. + let _ = images; if let Some(provider) = &self.options.provider { args.push("--provider".to_string()); args.push(provider.clone()); @@ -105,6 +105,15 @@ impl Thread { args.push("--permission-mode".to_string()); args.push(permission_mode.clone()); } + if self.options.dangerously_skip_permissions { + args.push("--dangerously-skip-permissions".to_string()); + } + if let Some(tools) = &self.options.allowed_tools { + for tool in tools { + args.push("--allowed-tools".to_string()); + args.push(tool.clone()); + } + } if let Some(id) = session_id { args.push("--resume".to_string()); args.push(id.to_string()); diff --git a/sdks/rust/tests/integration_test.rs b/sdks/rust/tests/integration_test.rs index 2d5943a..385b042 100644 --- a/sdks/rust/tests/integration_test.rs +++ b/sdks/rust/tests/integration_test.rs @@ -140,7 +140,7 @@ async fn subsequent_turns_pass_resume() { } #[tokio::test] -async fn images_emit_image_flags() { +async fn images_do_not_emit_image_flags() { let mock = MockFrontalCode::new(SINGLE_TURN, 0); let frontal_code = code_for(&mock); let entries = vec![ @@ -160,16 +160,49 @@ async fn images_emit_image_flags() { .await .expect("run"); let args = mock.captured_args(); - let image_indexes: Vec = args + assert!(!args.iter().any(|a| a == "--image")); + assert!(args.contains(&"describe these".to_string())); +} + +#[tokio::test] +async fn dangerously_skip_permissions_emits_flag() { + let mock = MockFrontalCode::new(SINGLE_TURN, 0); + let frontal_code = code_for(&mock); + frontal_code + .start_thread(ThreadOptions { + dangerously_skip_permissions: true, + ..Default::default() + }) + .run(&ThreadInput::Text("x".into()), &ThreadRunOptions::default()) + .await + .expect("run"); + assert!(mock + .captured_args() + .contains(&"--dangerously-skip-permissions".to_string())); +} + +#[tokio::test] +async fn allowed_tools_emit_flags() { + let mock = MockFrontalCode::new(SINGLE_TURN, 0); + let frontal_code = code_for(&mock); + frontal_code + .start_thread(ThreadOptions { + allowed_tools: Some(vec!["read".into(), "bash".into()]), + ..Default::default() + }) + .run(&ThreadInput::Text("x".into()), &ThreadRunOptions::default()) + .await + .expect("run"); + let args = mock.captured_args(); + let tool_indexes: Vec = args .iter() .enumerate() - .filter(|(_, a)| *a == "--image") + .filter(|(_, a)| *a == "--allowed-tools") .map(|(i, _)| i) .collect(); - assert_eq!(image_indexes.len(), 2); - assert_eq!(args[image_indexes[0] + 1], "./ui.png"); - assert_eq!(args[image_indexes[1] + 1], "./diagram.jpg"); - assert!(args.contains(&"describe these".to_string())); + assert_eq!(tool_indexes.len(), 2); + assert_eq!(args[tool_indexes[0] + 1], "read"); + assert_eq!(args[tool_indexes[1] + 1], "bash"); } #[tokio::test] diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 5a5a16a..f16ef43 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -15,10 +15,10 @@ Requires Node.js 18+. ## Quickstart ```typescript -import { Frontal Code } from "@frontal-labs/code-sdk"; +import { FrontalCode } from "@frontal-labs/code-sdk"; -const frontal-code = new Frontal Code(); -const thread = frontal-code.startThread(); +const frontal = new FrontalCode(); +const thread = frontal.startThread(); const turn = await thread.run("Diagnose the test failure and propose a fix"); console.log(turn.finalResponse); @@ -83,25 +83,13 @@ const turn = await thread.run("Summarize repository status", { console.log(turn.finalResponse); ``` -### Attaching images - -Provide structured input entries when you need to include images alongside text. Text entries are concatenated into the final prompt while image entries are passed to the Frontal Code CLI via `--image`. - -```typescript -const turn = await thread.run([ - { type: "text", text: "Describe these screenshots" }, - { type: "local_image", path: "./ui.png" }, - { type: "local_image", path: "./diagram.jpg" }, -]); -``` - ### Resuming an existing thread Threads are persisted in `~/.frontal-code/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. ```typescript -const savedThreadId = process.env.CODEX_THREAD_ID!; -const thread = frontal-code.resumeThread(savedThreadId); +const savedThreadId = process.env.FRONTAL_CODE_THREAD_ID!; +const thread = frontal.resumeThread(savedThreadId); await thread.run("Implement the fix"); ``` @@ -110,7 +98,7 @@ await thread.run("Implement the fix"); Frontal Code runs in the current working directory by default. To avoid unrecoverable errors, Frontal Code requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. ```typescript -const thread = frontal-code.startThread({ +const thread = frontal.startThread({ workingDirectory: "/path/to/project", skipGitRepoCheck: true, }); @@ -119,18 +107,19 @@ const thread = frontal-code.startThread({ ### Controlling the Frontal Code CLI environment By default, the Frontal Code CLI inherits the Node.js process environment. Provide the optional `env` parameter when instantiating the -`Frontal Code` client to fully control which variables the CLI receives—useful for sandboxed hosts like Electron apps. +`FrontalCode` client to fully control which variables the CLI receives—useful for sandboxed hosts like Electron apps. ```typescript -const frontal-code = new Frontal Code({ +const frontal = new FrontalCode({ env: { PATH: "/usr/local/bin", }, }); ``` -The SDK still injects its required variables (such as `CODEX_API_KEY`) on top of the environment you provide. If you set -`baseUrl`, the SDK passes it as a `--config frontal_base_url=...` override. +The SDK does not inject any environment variables of its own. Authentication is handled by the `frontal-code` CLI through +provider-specific environment variables (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `FRONTAL_API_KEY`) that you set in your +shell before running the agent. If you set `baseUrl`, the SDK passes it as a `--config frontal_base_url=...` override. ### Passing `--config` overrides @@ -138,7 +127,7 @@ Use the `config` option to provide additional Frontal Code CLI configuration ove into dotted paths, and serializes values as TOML literals before passing them as repeated `--config key=value` flags. ```typescript -const frontal-code = new Frontal Code({ +const frontal = new FrontalCode({ config: { show_raw_agent_reasoning: true, sandbox_workspace_write: { network_access: true }, @@ -146,4 +135,16 @@ const frontal-code = new Frontal Code({ }); ``` -Thread options still take precedence for overlapping settings because they are emitted after these global overrides. \ No newline at end of file +Thread options still take precedence for overlapping settings because they are emitted after these global overrides. + +## Permission control and tool restriction + +Thread options support the same permission and tool-restriction flags as the CLI: + +```typescript +const thread = frontal.startThread({ + permissionMode: "danger-full-access", // or read-only, workspace-write, prompt + dangerouslySkipPermissions: true, // --dangerously-skip-permissions + allowedTools: ["read", "bash"], --allowed-tools (repeatable) +}); +``` \ No newline at end of file diff --git a/sdks/typescript/docs/TESTING.md b/sdks/typescript/docs/TESTING.md index ab78861..05e2cea 100644 --- a/sdks/typescript/docs/TESTING.md +++ b/sdks/typescript/docs/TESTING.md @@ -4,7 +4,7 @@ Tests are fully hermetic - they do **not** require: - Network access -- API keys (`CODEX_API_KEY`) +- API keys - Real `frontal-code` CLI binary Instead, tests use a mock CLI (`tests/mock-frontal-code.ts`) that emits diff --git a/sdks/typescript/src/frontal-code.ts b/sdks/typescript/src/frontal-code.ts index bb9b5dc..19e13c7 100644 --- a/sdks/typescript/src/frontal-code.ts +++ b/sdks/typescript/src/frontal-code.ts @@ -2,8 +2,6 @@ import { defaultCommand } from "./command.js"; import type { FrontalCodeOptions, JsonValue, ThreadOptions } from "./protocol.js"; import { Thread } from "./thread.js"; -const REQUIRED_ENV_VARS = ["CODEX_API_KEY"]; - /** * The top-level Frontal Code client. Wraps the Frontal Code CLI (`frontal-code`, or `code`, the same * program) and is used to start and resume conversation {@link Thread}s. @@ -29,8 +27,10 @@ export class FrontalCode { /** * Build the environment for a spawned CLI. Starts from `process.env`, applies - * the user-provided `env`, then injects any required variables (such as - * `CODEX_API_KEY`) from the ambient environment if still missing. + * the user-provided `env`, then applies any `extra` overrides on top. + * The `frontal-code` CLI authenticates via provider-specific environment + * variables (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) which the caller + * is responsible for setting; this SDK does not inject any of its own. */ buildEnv(extra?: Record): NodeJS.ProcessEnv { const base: NodeJS.ProcessEnv = { ...process.env }; @@ -40,11 +40,6 @@ export class FrontalCode { if (extra) { Object.assign(base, extra); } - for (const varName of REQUIRED_ENV_VARS) { - if (!base[varName] && process.env[varName]) { - base[varName] = process.env[varName]; - } - } return base; } } diff --git a/sdks/typescript/src/protocol.ts b/sdks/typescript/src/protocol.ts index 0ef33ea..99d5b2f 100644 --- a/sdks/typescript/src/protocol.ts +++ b/sdks/typescript/src/protocol.ts @@ -76,6 +76,10 @@ export interface ThreadOptions { model?: string; /** Permission mode override. */ permissionMode?: string; + /** Skip all permission checks (maps to `--dangerously-skip-permissions`). */ + dangerouslySkipPermissions?: boolean; + /** Restrict enabled tools (maps to `--allowed-tools`, repeatable). */ + allowedTools?: string[]; /** Thread-level CLI `--config` overrides (take precedence over global). */ config?: Record; } diff --git a/sdks/typescript/src/spawn.ts b/sdks/typescript/src/spawn.ts index 3ab686a..d2c4362 100644 --- a/sdks/typescript/src/spawn.ts +++ b/sdks/typescript/src/spawn.ts @@ -106,6 +106,10 @@ export async function collectTurn(events: AsyncIterable): Prom finalResponse = event.finalResponse; usage = event.usage; sessionId = event.sessionId; + } else if (event.type === "turn.failed") { + throw new FrontalCodeCliError( + `frontal-code turn failed: ${(event as { error?: string }).error ?? "unknown error"}`, + ); } } diff --git a/sdks/typescript/src/thread.ts b/sdks/typescript/src/thread.ts index c08349d..a59f425 100644 --- a/sdks/typescript/src/thread.ts +++ b/sdks/typescript/src/thread.ts @@ -74,37 +74,37 @@ export class Thread { return { events: wrap() }; } - private toPromptAndImages(input: ThreadInput): { - prompt: string; - images: string[]; - } { + private toPrompt(input: ThreadInput): string { if (typeof input === "string") { - return { prompt: input, images: [] }; + return input; } + // `local_image` entries are kept in the protocol for forward compatibility, + // but the `frontal-code` CLI does not yet accept `--image`, so only text + // entries are concatenated into the prompt. const texts: string[] = []; - const images: string[] = []; for (const entry of input as InputEntry[]) { if (entry.type === "text") { texts.push(entry.text); - } else if (entry.type === "local_image") { - images.push(entry.path); } } - return { prompt: texts.join("\n"), images }; + return texts.join("\n"); } private buildArgs(input: ThreadInput, runOptions: ThreadRunOptions): string[] { - const { prompt, images } = this.toPromptAndImages(input); + const prompt = this.toPrompt(input); const args = ["prompt", "-p", prompt]; - for (const image of images) { - args.push("--image", image); - } if (this.options.provider) args.push("--provider", this.options.provider); if (this.options.model) args.push("--model", this.options.model); if (this.options.permissionMode) { args.push("--permission-mode", this.options.permissionMode); } + if (this.options.dangerouslySkipPermissions) { + args.push("--dangerously-skip-permissions"); + } + for (const tool of this.options.allowedTools ?? []) { + args.push("--allowed-tools", tool); + } args.push("--output-format", "json", "--stream"); if (this.sessionId) { diff --git a/sdks/typescript/tests/frontal-code.test.ts b/sdks/typescript/tests/frontal-code.test.ts index 1506ec5..973d6a8 100644 --- a/sdks/typescript/tests/frontal-code.test.ts +++ b/sdks/typescript/tests/frontal-code.test.ts @@ -31,31 +31,6 @@ describe("Frontal Code client", () => { expect(env.PATH).toBeDefined(); }); - it("injects required variables from the ambient environment", () => { - const previous = process.env.CODEX_API_KEY; - process.env.CODEX_API_KEY = "injected-key"; - try { - const frontal_code = new FrontalCode({ env: {} }); - const env = frontal_code.buildEnv(); - expect(env.CODEX_API_KEY).toBe("injected-key"); - } finally { - if (previous === undefined) delete process.env.CODEX_API_KEY; - else process.env.CODEX_API_KEY = previous; - } - }); - - it("does not overwrite a user-provided required variable", () => { - const previous = process.env.CODEX_API_KEY; - process.env.CODEX_API_KEY = "ambient"; - try { - const frontal_code = new FrontalCode({ env: { CODEX_API_KEY: "explicit" } }); - expect(frontal_code.buildEnv().CODEX_API_KEY).toBe("explicit"); - } finally { - if (previous === undefined) delete process.env.CODEX_API_KEY; - else process.env.CODEX_API_KEY = previous; - } - }); - it("routes spawned CLI calls to a mock binary", async () => { const mock = new MockFrontalCode({ response: '{"type":"turn.completed","finalResponse":"hi","sessionId":"s1"}', diff --git a/sdks/typescript/tests/thread.test.ts b/sdks/typescript/tests/thread.test.ts index b9ca8e8..f56082b 100644 --- a/sdks/typescript/tests/thread.test.ts +++ b/sdks/typescript/tests/thread.test.ts @@ -78,27 +78,42 @@ describe("Thread.run", () => { expect(args[resumeIndex + 1]).toBe("sess-1"); }); - it("emits --image for each local image entry", async () => { + it("concatenates multiple text entries into the prompt", async () => { const entries: InputEntry[] = [ - { type: "text", text: "describe these" }, - { type: "local_image", path: "./ui.png" }, - { type: "local_image", path: "./diagram.jpg" }, + { type: "text", text: "line one" }, + { type: "text", text: "line two" }, ]; const { args } = await capture((frontal_code) => frontal_code.startThread().run(entries)); - const imageIndexes = args.map((a, i) => (a === "--image" ? i : -1)).filter((i) => i > -1); - expect(imageIndexes).toHaveLength(2); - expect(args[imageIndexes[0] + 1]).toBe("./ui.png"); - expect(args[imageIndexes[1] + 1]).toBe("./diagram.jpg"); - expect(args).toContain("describe these"); + expect(args).toContain("line one\nline two"); }); - it("concatenates multiple text entries into the prompt", async () => { + it("does not pass --image (the CLI does not yet accept it)", async () => { const entries: InputEntry[] = [ - { type: "text", text: "line one" }, - { type: "text", text: "line two" }, + { type: "text", text: "describe this" }, + { type: "local_image", path: "./ui.png" }, ]; const { args } = await capture((frontal_code) => frontal_code.startThread().run(entries)); - expect(args).toContain("line one\nline two"); + expect(args).not.toContain("--image"); + expect(args).toContain("describe this"); + }); + + it("passes --dangerously-skip-permissions when set", async () => { + const { args } = await capture((frontal_code) => + frontal_code.startThread({ dangerouslySkipPermissions: true }).run("x"), + ); + expect(args).toContain("--dangerously-skip-permissions"); + }); + + it("passes --allowed-tools for each tool", async () => { + const { args } = await capture((frontal_code) => + frontal_code.startThread({ allowedTools: ["read", "bash"] }).run("x"), + ); + const toolIndexes = args + .map((a, i) => (a === "--allowed-tools" ? i : -1)) + .filter((i) => i > -1); + expect(toolIndexes).toHaveLength(2); + expect(args[toolIndexes[0] + 1]).toBe("read"); + expect(args[toolIndexes[1] + 1]).toBe("bash"); }); it("passes provider, model, and permission mode", async () => {