Compare commits

...

5 Commits

Author SHA1 Message Date
sdcoffey
3d881fb590 ci: avoid publishing musl runtime wheels to PyPI 2026-03-13 17:16:24 -07:00
sdcoffey
2fc0ca1ec8 sdk/python: always regenerate types before staging 2026-03-13 17:04:21 -07:00
sdcoffey
c9230a345d ci: publish python sdk releases to PyPI 2026-03-13 16:56:50 -07:00
sdcoffey
a0b88262da sdk/python: split core and bundled packages 2026-03-13 16:55:55 -07:00
Andrei Eternal
9a44a7e499 [hooks] stop continuation & stop_hook_active mechanics (#14532)
Stop hooks now receive `stop_hook_active` and enable stop hooks to loop
forever if they'd like to. In the initial hooks PR, we implemented a
simpler mechanic that the stop-blocking could only happen once in a row

- support stop hook adding a continuation prompt to add a further task
- if multiple stop-blocks happen that have continuation prompts, they
are concatenated

example run:
```
› hey :)


• Running SessionStart hook: lighting the observatory

SessionStart hook (completed)
  warning: Hi, I'm a session start hook for wizard-tower (startup).
  hook context: Startup context: A wimboltine stonpet is an exotic cuisine from hyperspace

• Aloha :) Happy to jam with you. What are we building today?

• Running Stop hook: updating the guards

Stop hook (blocked)
  warning: Wizard Tower Stop hook continuing conversation
  feedback: cook the stonpet

• Aloha, here’s the hyperspace move for cooking a wimboltine stonpet:

  1. Sear the stonpet in a hot pan with moon-oil until the edges shimmer.
  2. Add star-lime, black salt, and a little fermented nebula paste.
  3. Lower the heat and let it braise for 8 cosmic minutes with a splash of comet broth.
  4. Finish with sky herbs and serve over warm asteroid rice.

  The vibe: crispy outside, tender center, deep interdimensional savor.

• Running Stop hook: updating the guards

Stop hook (completed)
  warning: Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop.
```

.codex/config.toml
```
[features]
codex_hooks = true
```

.codex/hooks.json
```
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 .codex/hooks/session_start_demo.py",
            "timeoutSec": 10,
            "statusMessage": "lighting the observatory"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 .codex/hooks/stop_demo_block.py",
            "timeoutSec": 10,
            "statusMessage": "updating the guards"
          }
        ]
      }
    ]
  }
}
```

.codex/hooks/session_start_demo.py
```
#!/usr/bin/env python3

import json
import sys
from pathlib import Path


def main() -> int:
    payload = json.load(sys.stdin)
    cwd = Path(payload.get("cwd", ".")).name or "wizard-tower"
    source = payload.get("source", "startup")
    source_label = "resume" if source == "resume" else "startup"
    source_prefix = (
        "Resume context:"
        if source == "resume"
        else "Startup context:"
    )

    output = {
        "systemMessage": (
            f"Hi, I'm a session start hook for {cwd} ({source_label})."
        ),
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": (
                f"{source_prefix} A wimboltine stonpet is an exotic cuisine from hyperspace"
            ),
        },
    }
    print(json.dumps(output))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

.codex/hooks/stop_demo_block.py
```
#!/usr/bin/env python3

import json
import sys


def main() -> int:
    payload = json.load(sys.stdin)
    stop_hook_active = payload.get("stop_hook_active", False)
    last_assistant_message = payload.get("last_assistant_message") or ""
    char_count = len(last_assistant_message.strip())

    if stop_hook_active:
        system_message = (
            "Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop."
        )
        print(json.dumps({"systemMessage": system_message}))
    else:
        system_message = (
            f"Wizard Tower Stop hook continuing conversation"
        )
        print(json.dumps({"systemMessage": system_message, "decision": "block", "reason": "cook the stonpet"}))

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```
2026-03-13 15:51:19 -07:00
15 changed files with 927 additions and 159 deletions

View File

@@ -181,6 +181,41 @@ jobs:
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
- name: Setup Python for runtime packaging
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Build Python runtime wheel
shell: bash
working-directory: ${{ github.workspace }}
env:
RELEASE_TAG: ${{ github.ref_name }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
version="${RELEASE_TAG#rust-v}"
staging_dir="${RUNNER_TEMP}/codex-cli-bin-${TARGET}"
out_dir="${GITHUB_WORKSPACE}/dist-python/${TARGET}"
python -m pip install --upgrade pip
python -m pip install build hatchling
python sdk/python/scripts/update_sdk_artifacts.py \
stage-runtime \
"${staging_dir}" \
"codex-rs/target/${TARGET}/release/codex.exe" \
--runtime-version "${version}"
python -m build --wheel --outdir "${out_dir}" "${staging_dir}"
- name: Upload Python runtime wheel
uses: actions/upload-artifact@v7
with:
name: python-runtime-${{ matrix.target }}
path: dist-python/${{ matrix.target }}/*
- name: Stage artifacts
shell: bash
run: |

View File

@@ -302,6 +302,41 @@ jobs:
apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
- name: Setup Python for runtime packaging
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Build Python runtime wheel
shell: bash
working-directory: ${{ github.workspace }}
env:
RELEASE_TAG: ${{ github.ref_name }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
version="${RELEASE_TAG#rust-v}"
staging_dir="${RUNNER_TEMP}/codex-cli-bin-${TARGET}"
out_dir="${GITHUB_WORKSPACE}/dist-python/${TARGET}"
python -m pip install --upgrade pip
python -m pip install build hatchling
python sdk/python/scripts/update_sdk_artifacts.py \
stage-runtime \
"${staging_dir}" \
"codex-rs/target/${TARGET}/release/codex" \
--runtime-version "${version}"
python -m build --wheel --outdir "${out_dir}" "${staging_dir}"
- name: Upload Python runtime wheel
uses: actions/upload-artifact@v7
with:
name: python-runtime-${{ matrix.target }}
path: dist-python/${{ matrix.target }}/*
- name: Stage artifacts
shell: bash
run: |
@@ -384,6 +419,7 @@ jobs:
needs:
- build
- build-windows
- build-python-sdk
- shell-tool-mcp
name: release
runs-on: ubuntu-latest
@@ -533,6 +569,190 @@ jobs:
exit 1
fi
build-python-sdk:
name: build-python-sdk
needs:
- tag-check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Build Python SDK artifacts
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#rust-v}"
core_staging_dir="${RUNNER_TEMP}/codex-app-server-sdk-core"
core_out_dir="${GITHUB_WORKSPACE}/dist-python/sdk-core"
bundled_staging_dir="${RUNNER_TEMP}/codex-app-server-sdk"
bundled_out_dir="${GITHUB_WORKSPACE}/dist-python/sdk"
python -m pip install --upgrade pip
python -m pip install \
build \
hatchling \
"datamodel-code-generator==0.31.2" \
"ruff==0.11.13"
python sdk/python/scripts/update_sdk_artifacts.py generate-types
python sdk/python/scripts/update_sdk_artifacts.py \
stage-sdk-core \
"${core_staging_dir}" \
--sdk-version "${version}"
python -m build --outdir "${core_out_dir}" "${core_staging_dir}"
python sdk/python/scripts/update_sdk_artifacts.py \
stage-sdk \
"${bundled_staging_dir}" \
--sdk-version "${version}" \
--runtime-version "${version}"
python -m build --outdir "${bundled_out_dir}" "${bundled_staging_dir}"
- name: Upload Python core SDK artifacts
uses: actions/upload-artifact@v7
with:
name: python-sdk-core
path: dist-python/sdk-core/*
- name: Upload Python SDK artifacts
uses: actions/upload-artifact@v7
with:
name: python-sdk
path: dist-python/sdk/*
publish-pypi-runtime:
name: publish-pypi-runtime
needs:
- build
- build-windows
- release
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
environment:
name: pypi
steps:
# Do not publish musl runtime wheels to PyPI yet. GNU and musl builds for
# the same architecture currently infer the same wheel tag, which can make
# the published Linux runtime nondeterministic.
- name: Download macOS arm64 runtime wheel
uses: actions/download-artifact@v8
with:
name: python-runtime-aarch64-apple-darwin
path: dist-pypi/runtime
- name: Download macOS x64 runtime wheel
uses: actions/download-artifact@v8
with:
name: python-runtime-x86_64-apple-darwin
path: dist-pypi/runtime
- name: Download Linux GNU arm64 runtime wheel
uses: actions/download-artifact@v8
with:
name: python-runtime-aarch64-unknown-linux-gnu
path: dist-pypi/runtime
- name: Download Linux GNU x64 runtime wheel
uses: actions/download-artifact@v8
with:
name: python-runtime-x86_64-unknown-linux-gnu
path: dist-pypi/runtime
- name: Download Windows arm64 runtime wheel
uses: actions/download-artifact@v8
with:
name: python-runtime-aarch64-pc-windows-msvc
path: dist-pypi/runtime
- name: Download Windows x64 runtime wheel
uses: actions/download-artifact@v8
with:
name: python-runtime-x86_64-pc-windows-msvc
path: dist-pypi/runtime
- name: List runtime wheels
shell: bash
run: ls -R dist-pypi/runtime
- name: Publish Python runtime wheels to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist-pypi/runtime/
publish-pypi-sdk-core:
name: publish-pypi-sdk-core
needs:
- build-python-sdk
- publish-pypi-runtime
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
environment:
name: pypi
steps:
- name: Download Python core SDK artifacts
uses: actions/download-artifact@v8
with:
name: python-sdk-core
path: dist-pypi/sdk-core
- name: List core SDK artifacts
shell: bash
run: ls -R dist-pypi/sdk-core
- name: Publish Python core SDK to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist-pypi/sdk-core/
publish-pypi-sdk:
name: publish-pypi-sdk
needs:
- build-python-sdk
- publish-pypi-sdk-core
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
environment:
name: pypi
steps:
- name: Download bundled Python SDK artifacts
uses: actions/download-artifact@v8
with:
name: python-sdk
path: dist-pypi/sdk
- name: List bundled SDK artifacts
shell: bash
run: ls -R dist-pypi/sdk
- name: Publish bundled Python SDK to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist-pypi/sdk/
# Publish to npm using OIDC authentication.
# July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/
# npm docs: https://docs.npmjs.com/trusted-publishers

View File

@@ -5652,7 +5652,6 @@ pub(crate) async fn run_turn(
.await;
let mut last_agent_message: Option<String> = None;
let mut stop_hook_active = false;
let mut pending_stop_hook_message: Option<String> = None;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
@@ -5744,14 +5743,11 @@ pub(crate) async fn run_turn(
}
// Construct the input that we will send to the model.
let mut sampling_request_input: Vec<ResponseItem> = {
let sampling_request_input: Vec<ResponseItem> = {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
};
if let Some(stop_hook_message) = pending_stop_hook_message.take() {
sampling_request_input.push(DeveloperInstructions::new(stop_hook_message).into());
}
let sampling_request_input_messages = sampling_request_input
.iter()
@@ -5848,18 +5844,25 @@ pub(crate) async fn run_turn(
.await;
}
if stop_outcome.should_block {
if stop_hook_active {
if let Some(continuation_prompt) = stop_outcome.continuation_prompt.clone()
{
let developer_message: ResponseItem =
DeveloperInstructions::new(continuation_prompt).into();
sess.record_conversation_items(
&turn_context,
std::slice::from_ref(&developer_message),
)
.await;
stop_hook_active = true;
continue;
} else {
sess.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: "Stop hook blocked twice in the same turn; ignoring the second block to avoid an infinite loop.".to_string(),
message: "Stop hook requested continuation without a prompt; ignoring the block.".to_string(),
}),
)
.await;
} else {
stop_hook_active = true;
pending_stop_hook_message = stop_outcome.block_message_for_model;
continue;
}
}
if stop_outcome.should_stop {

View File

@@ -0,0 +1,271 @@
use std::fs;
use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use codex_core::features::Feature;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
const FIRST_CONTINUATION_PROMPT: &str = "Retry with exactly the phrase meow meow meow.";
const SECOND_CONTINUATION_PROMPT: &str = "Now tighten it to just: meow.";
fn write_stop_hook(home: &Path, block_prompts: &[&str]) -> Result<()> {
let script_path = home.join("stop_hook.py");
let log_path = home.join("stop_hook_log.jsonl");
let prompts_json =
serde_json::to_string(block_prompts).context("serialize stop hook prompts for test")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
log_path = Path(r"{log_path}")
block_prompts = {prompts_json}
payload = json.load(sys.stdin)
existing = []
if log_path.exists():
existing = [line for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
invocation_index = len(existing)
if invocation_index < len(block_prompts):
print(json.dumps({{"decision": "block", "reason": block_prompts[invocation_index]}}))
else:
print(json.dumps({{"systemMessage": f"stop hook pass {{invocation_index + 1}} complete"}}))
"#,
log_path = log_path.display(),
prompts_json = prompts_json,
);
let hooks = serde_json::json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running stop hook",
}]
}]
}
});
fs::write(&script_path, script).context("write stop hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn rollout_developer_texts(text: &str) -> Result<Vec<String>> {
let mut texts = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let rollout: RolloutLine = serde_json::from_str(trimmed).context("parse rollout line")?;
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rollout.item
&& role == "developer"
{
for item in content {
if let ContentItem::InputText { text } = item {
texts.push(text);
}
}
}
}
Ok(texts)
}
fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("stop_hook_log.jsonl"))
.context("read stop hook log")?
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse stop hook log line"))
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "draft one"),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-2", "draft two"),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_assistant_message("msg-3", "final draft"),
ev_completed("resp-3"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_stop_hook(
home,
&[FIRST_CONTINUATION_PROMPT, SECOND_CONTINUATION_PROMPT],
) {
panic!("failed to write stop hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("hello from the sea").await?;
let requests = responses.requests();
assert_eq!(requests.len(), 3);
assert!(
requests[1]
.message_input_texts("developer")
.contains(&FIRST_CONTINUATION_PROMPT.to_string()),
"second request should include the first continuation prompt",
);
assert!(
requests[2]
.message_input_texts("developer")
.contains(&FIRST_CONTINUATION_PROMPT.to_string()),
"third request should retain the first continuation prompt from history",
);
assert!(
requests[2]
.message_input_texts("developer")
.contains(&SECOND_CONTINUATION_PROMPT.to_string()),
"third request should include the second continuation prompt",
);
let hook_inputs = read_stop_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 3);
assert_eq!(
hook_inputs
.iter()
.map(|input| input["stop_hook_active"]
.as_bool()
.expect("stop_hook_active bool"))
.collect::<Vec<_>>(),
vec![false, true, true],
);
let rollout_path = test.codex.rollout_path().expect("rollout path");
let rollout_text = fs::read_to_string(&rollout_path)?;
let developer_texts = rollout_developer_texts(&rollout_text)?;
assert!(
developer_texts.contains(&FIRST_CONTINUATION_PROMPT.to_string()),
"rollout should persist the first continuation prompt",
);
assert!(
developer_texts.contains(&SECOND_CONTINUATION_PROMPT.to_string()),
"rollout should persist the second continuation prompt",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let initial_responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "initial draft"),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-2", "revised draft"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut initial_builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_stop_hook(home, &[FIRST_CONTINUATION_PROMPT]) {
panic!("failed to write stop hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let initial = initial_builder.build(&server).await?;
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
initial.submit_turn("tell me something").await?;
assert_eq!(initial_responses.requests().len(), 2);
let resumed_response = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-3"),
ev_assistant_message("msg-3", "fresh turn after resume"),
ev_completed("resp-3"),
]),
)
.await;
let mut resume_builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
resumed.submit_turn("and now continue").await?;
let resumed_request = resumed_response.single_request();
assert!(
resumed_request
.message_input_texts("developer")
.contains(&FIRST_CONTINUATION_PROMPT.to_string()),
"resumed request should keep the persisted continuation prompt in history",
);
Ok(())
}

View File

@@ -77,6 +77,8 @@ mod exec_policy;
mod fork_thread;
mod grep_files;
mod hierarchical_agents;
#[cfg(not(target_os = "windows"))]
mod hooks;
mod image_rollout;
mod items;
mod js_repl;

View File

@@ -24,6 +24,7 @@
},
"reason": {
"default": null,
"description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.",
"type": "string"
},
"stopReason": {

View File

@@ -17,6 +17,7 @@ pub(crate) struct StopOutput {
pub universal: UniversalOutput,
pub should_block: bool,
pub reason: Option<String>,
pub invalid_block_reason: Option<String>,
}
use crate::schema::HookUniversalOutputWire;
@@ -37,10 +38,21 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(StopDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
Some(reason) => reason.trim().is_empty(),
None => true,
} {
Some(invalid_block_message())
} else {
None
};
Some(StopOutput {
universal: UniversalOutput::from(wire.universal),
should_block: matches!(wire.decision, Some(StopDecisionWire::Block)),
should_block: should_block && invalid_block_reason.is_none(),
reason: wire.reason,
invalid_block_reason,
})
}
@@ -69,3 +81,7 @@ where
}
serde_json::from_value(value).ok()
}
fn invalid_block_message() -> String {
"Stop hook returned decision:block without a non-empty reason".to_string()
}

View File

@@ -34,16 +34,16 @@ pub struct StopOutcome {
pub stop_reason: Option<String>,
pub should_block: bool,
pub block_reason: Option<String>,
pub block_message_for_model: Option<String>,
pub continuation_prompt: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Default, PartialEq, Eq)]
struct StopHandlerData {
should_stop: bool,
stop_reason: Option<String>,
should_block: bool,
block_reason: Option<String>,
block_message_for_model: Option<String>,
continuation_prompt: Option<String>,
}
pub(crate) fn preview(
@@ -69,7 +69,7 @@ pub(crate) async fn run(
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
continuation_prompt: None,
};
}
@@ -102,34 +102,15 @@ pub(crate) async fn run(
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let should_block = !should_stop && results.iter().any(|result| result.data.should_block);
let block_reason = if should_block {
results
.iter()
.find_map(|result| result.data.block_reason.clone())
} else {
None
};
let block_message_for_model = if should_block {
results
.iter()
.find_map(|result| result.data.block_message_for_model.clone())
} else {
None
};
let aggregate = aggregate_results(results.iter().map(|result| &result.data));
StopOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
should_block,
block_reason,
block_message_for_model,
should_stop: aggregate.should_stop,
stop_reason: aggregate.stop_reason,
should_block: aggregate.should_block,
block_reason: aggregate.block_reason,
continuation_prompt: aggregate.continuation_prompt,
}
}
@@ -144,7 +125,7 @@ fn parse_completed(
let mut stop_reason = None;
let mut should_block = false;
let mut block_reason = None;
let mut block_message_for_model = None;
let mut continuation_prompt = None;
match run_result.error.as_deref() {
Some(error) => {
@@ -176,12 +157,18 @@ fn parse_completed(
text: stop_reason_text,
});
}
} else if let Some(invalid_block_reason) = parsed.invalid_block_reason {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_block_reason,
});
} else if parsed.should_block {
if let Some(reason) = parsed.reason.as_deref().and_then(trimmed_non_empty) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
block_message_for_model = Some(reason.clone());
continuation_prompt = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
@@ -190,8 +177,9 @@ fn parse_completed(
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason"
.to_string(),
text:
"Stop hook returned decision:block without a non-empty reason"
.to_string(),
});
}
}
@@ -208,7 +196,7 @@ fn parse_completed(
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
block_message_for_model = Some(reason.clone());
continuation_prompt = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
@@ -217,7 +205,9 @@ fn parse_completed(
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited with code 2 without stderr feedback".to_string(),
text:
"Stop hook exited with code 2 but did not write a continuation prompt to stderr"
.to_string(),
});
}
}
@@ -250,11 +240,57 @@ fn parse_completed(
stop_reason,
should_block,
block_reason,
block_message_for_model,
continuation_prompt,
},
}
}
fn aggregate_results<'a>(
results: impl IntoIterator<Item = &'a StopHandlerData>,
) -> StopHandlerData {
let results = results.into_iter().collect::<Vec<_>>();
let should_stop = results.iter().any(|result| result.should_stop);
let stop_reason = results.iter().find_map(|result| result.stop_reason.clone());
let should_block = !should_stop && results.iter().any(|result| result.should_block);
let block_reason = if should_block {
join_block_text(results.iter().copied(), |result| {
result.block_reason.as_deref()
})
} else {
None
};
let continuation_prompt = if should_block {
join_block_text(results.iter().copied(), |result| {
result.continuation_prompt.as_deref()
})
} else {
None
};
StopHandlerData {
should_stop,
stop_reason,
should_block,
block_reason,
continuation_prompt,
}
}
fn join_block_text<'a>(
results: impl IntoIterator<Item = &'a StopHandlerData>,
select: impl Fn(&'a StopHandlerData) -> Option<&'a str>,
) -> Option<String> {
let parts = results
.into_iter()
.filter_map(select)
.map(str::to_owned)
.collect::<Vec<_>>();
if parts.is_empty() {
return None;
}
Some(parts.join("\n\n"))
}
fn trimmed_non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
if !trimmed.is_empty() {
@@ -292,7 +328,7 @@ fn serialization_failure_outcome(
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
continuation_prompt: None,
}
}
@@ -307,10 +343,55 @@ mod tests {
use pretty_assertions::assert_eq;
use super::StopHandlerData;
use super::aggregate_results;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn block_decision_with_reason_sets_continuation_prompt() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"decision":"block","reason":"retry with tests"}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
block_reason: Some("retry with tests".to_string()),
continuation_prompt: Some("retry with tests".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
}
#[test]
fn block_decision_without_reason_is_invalid() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), r#"{"decision":"block"}"#, ""),
Some("turn-1".to_string()),
);
assert_eq!(parsed.data, StopHandlerData::default());
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "Stop hook returned decision:block without a non-empty reason".to_string(),
}]
);
}
#[test]
fn continue_false_overrides_block_decision() {
let parsed = parse_completed(
@@ -330,7 +411,7 @@ mod tests {
stop_reason: Some("done".to_string()),
should_block: false,
block_reason: None,
block_message_for_model: None,
continuation_prompt: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
@@ -351,36 +432,25 @@ mod tests {
stop_reason: None,
should_block: true,
block_reason: Some("retry with tests".to_string()),
block_message_for_model: Some("retry with tests".to_string()),
continuation_prompt: Some("retry with tests".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
}
#[test]
fn block_decision_without_reason_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), r#"{"decision":"block"}"#, ""),
Some("turn-1".to_string()),
);
fn exit_code_two_without_stderr_does_not_block() {
let parsed = parse_completed(&handler(), run_result(Some(2), "", " "), None);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.data, StopHandlerData::default());
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
text:
"Stop hook exited with code 2 but did not write a continuation prompt to stderr"
.to_string(),
}]
);
}
@@ -393,50 +463,13 @@ mod tests {
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.data, StopHandlerData::default());
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
}]
);
}
#[test]
fn exit_code_two_without_stderr_feedback_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "ignored stdout", " "),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited with code 2 without stderr feedback".to_string(),
text: "Stop hook returned decision:block without a non-empty reason".to_string(),
}]
);
}
@@ -449,16 +482,7 @@ mod tests {
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.data, StopHandlerData::default());
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
@@ -469,6 +493,37 @@ mod tests {
);
}
#[test]
fn aggregate_results_concatenates_blocking_reasons_in_declaration_order() {
let aggregate = aggregate_results([
&StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
block_reason: Some("first".to_string()),
continuation_prompt: Some("first".to_string()),
},
&StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
block_reason: Some("second".to_string()),
continuation_prompt: Some("second".to_string()),
},
]);
assert_eq!(
aggregate,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
block_reason: Some("first\n\nsecond".to_string()),
continuation_prompt: Some("first\n\nsecond".to_string()),
}
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::Stop,

View File

@@ -96,6 +96,8 @@ pub(crate) struct StopCommandOutputWire {
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<StopDecisionWire>,
/// Claude requires `reason` when `decision` is `block`; we enforce that
/// semantic rule during output parsing rather than in the JSON schema.
#[serde(default)]
pub reason: Option<String>,
}

View File

@@ -11,9 +11,16 @@ cd sdk/python
python -m pip install -e .
```
Published SDK builds pin an exact `codex-cli-bin` runtime dependency. For local
repo development, pass `AppServerConfig(codex_bin=...)` to point at a local
build explicitly.
This checked-in package is the runtime-free core distribution:
`codex-app-server-sdk-core`.
Published releases expose two Python package names:
- `codex-app-server-sdk-core`: the actual Python SDK code, without bundled binaries
- `codex-app-server-sdk`: a bundled metapackage that depends on `codex-app-server-sdk-core` and `codex-cli-bin`
For local repo development, pass `AppServerConfig(codex_bin=...)` to point at
a local build explicitly.
## Quickstart
@@ -48,9 +55,9 @@ python examples/01_quickstart_constructor/async.py
The repo no longer checks `codex` binaries into `sdk/python`.
Published SDK builds are pinned to an exact `codex-cli-bin` package version,
and that runtime package carries the platform-specific binary for the target
wheel.
Published `codex-app-server-sdk` builds depend on an exact
`codex-app-server-sdk-core` version plus an exact `codex-cli-bin` version, and
that runtime package carries the platform-specific binary for the target wheel.
For local repo development, the checked-in `sdk/python-runtime` package is only
a template for staged release artifacts. Editable installs should use an
@@ -61,9 +68,14 @@ explicit `codex_bin` override instead.
```bash
cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk-core \
/tmp/codex-python-release/codex-app-server-sdk-core \
--sdk-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/codex-app-server-sdk \
--sdk-version 1.2.3 \
--runtime-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-runtime \
@@ -75,17 +87,22 @@ python scripts/update_sdk_artifacts.py \
This supports the CI release flow:
- run `generate-types` before packaging
- stage `codex-app-server-sdk` once with an exact `codex-cli-bin==...` dependency
- stage `codex-app-server-sdk-core` with the release tag version as `--sdk-version`
- stage `codex-app-server-sdk` as a bundled metapackage pinned to exact `codex-app-server-sdk-core==...` and `codex-cli-bin==...`
- stage `codex-cli-bin` on each supported platform runner with the same pinned runtime version
- build and publish `codex-cli-bin` as platform wheels only; do not publish an sdist
- publish `codex-app-server-sdk-core` to PyPI after the runtime wheels land
- publish `codex-app-server-sdk` to PyPI last, using the same release version
## Compatibility and versioning
- Package: `codex-app-server-sdk`
- Core package: `codex-app-server-sdk-core`
- Bundled package: `codex-app-server-sdk`
- Runtime package: `codex-cli-bin`
- Current SDK version in this repo: `0.2.0`
- Python: `>=3.10`
- Target protocol: Codex `app-server` JSON-RPC v2
- Release policy: published Python package versions should match the `rust-v...` release tag
- Recommendation: keep SDK and `codex` CLI reasonably up to date together
## Notes

View File

@@ -38,16 +38,23 @@ Common causes:
- local auth/session is missing
- incompatible/old app-server
Maintainers stage releases by building the SDK once and the runtime once per
platform with the same pinned runtime version. Publish `codex-cli-bin` as
platform wheels only; do not publish an sdist:
Maintainers stage releases by building the core SDK once, the bundled SDK
metapackage once, and the runtime once per platform with the same pinned
runtime version. Publish `codex-cli-bin` as platform wheels only; do not
publish an sdist. Published Python package versions should match the
`rust-v...` release tag:
```bash
cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk-core \
/tmp/codex-python-release/codex-app-server-sdk-core \
--sdk-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/codex-app-server-sdk \
--sdk-version 1.2.3 \
--runtime-version 1.2.3
python scripts/update_sdk_artifacts.py \
stage-runtime \

View File

@@ -3,9 +3,9 @@ requires = ["hatchling>=1.24.0"]
build-backend = "hatchling.build"
[project]
name = "codex-app-server-sdk"
name = "codex-app-server-sdk-core"
version = "0.2.0"
description = "Python SDK for Codex app-server v2"
description = "Core Python SDK for Codex app-server v2"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }

View File

@@ -15,8 +15,13 @@ import types
import typing
from dataclasses import dataclass
from pathlib import Path
from textwrap import dedent
from typing import Any, Callable, Sequence, get_args, get_origin
CORE_SDK_PKG_NAME = "codex-app-server-sdk-core"
BUNDLED_SDK_PKG_NAME = "codex-app-server-sdk"
RUNTIME_PKG_NAME = "codex-cli-bin"
def repo_root() -> Path:
return Path(__file__).resolve().parents[3]
@@ -110,23 +115,7 @@ def _rewrite_project_version(pyproject_text: str, version: str) -> str:
return updated
def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -> str:
match = re.search(r"^dependencies = \[(.*?)\]$", pyproject_text, flags=re.MULTILINE)
if match is None:
raise RuntimeError(
"Could not find dependencies array in sdk/python/pyproject.toml"
)
raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()]
raw_items = [item for item in raw_items if "codex-cli-bin" not in item]
raw_items.append(f'"codex-cli-bin=={runtime_version}"')
replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]"
return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :]
def stage_python_sdk_package(
staging_dir: Path, sdk_version: str, runtime_version: str
) -> Path:
def stage_python_core_sdk_package(staging_dir: Path, sdk_version: str) -> Path:
_copy_package_tree(sdk_root(), staging_dir)
sdk_bin_dir = staging_dir / "src" / "codex_app_server" / "bin"
if sdk_bin_dir.exists():
@@ -135,11 +124,73 @@ def stage_python_sdk_package(
pyproject_path = staging_dir / "pyproject.toml"
pyproject_text = pyproject_path.read_text()
pyproject_text = _rewrite_project_version(pyproject_text, sdk_version)
pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, runtime_version)
pyproject_path.write_text(pyproject_text)
return staging_dir
def stage_python_sdk_package(
staging_dir: Path, sdk_version: str, runtime_version: str
) -> Path:
if staging_dir.exists():
if staging_dir.is_dir():
shutil.rmtree(staging_dir)
else:
staging_dir.unlink()
package_dir = staging_dir / "src" / "codex_app_server_sdk_meta"
package_dir.mkdir(parents=True, exist_ok=True)
(package_dir / "__init__.py").write_text(
'"""Bundled Codex app-server SDK package metadata."""\n'
)
pyproject = dedent(
f"""
[build-system]
requires = ["hatchling>=1.24.0"]
build-backend = "hatchling.build"
[project]
name = "{BUNDLED_SDK_PKG_NAME}"
version = "{sdk_version}"
description = "Bundled Python SDK for Codex app-server v2"
readme = "README.md"
requires-python = ">=3.10"
license = {{ text = "Apache-2.0" }}
authors = [{{ name = "OpenClaw Assistant" }}]
dependencies = [
"{CORE_SDK_PKG_NAME}=={sdk_version}",
"{RUNTIME_PKG_NAME}=={runtime_version}",
]
[project.urls]
Homepage = "https://github.com/openai/codex"
Repository = "https://github.com/openai/codex"
Issues = "https://github.com/openai/codex/issues"
[tool.hatch.build.targets.wheel]
packages = ["src/codex_app_server_sdk_meta"]
[tool.hatch.build.targets.sdist]
include = ["src/codex_app_server_sdk_meta/**", "README.md", "pyproject.toml"]
"""
).lstrip()
(staging_dir / "pyproject.toml").write_text(pyproject)
(staging_dir / "README.md").write_text(
"\n".join(
[
"# Codex App Server Python SDK",
"",
"Bundled metapackage for the Codex app-server Python SDK.",
f"It depends on `{CORE_SDK_PKG_NAME}` and `{RUNTIME_PKG_NAME}`",
"at the same version so a regular install includes both the SDK",
"and the packaged Codex runtime binary.",
"",
]
)
)
return staging_dir
def stage_python_runtime_package(
staging_dir: Path, runtime_version: str, binary_path: Path
) -> Path:
@@ -558,6 +609,7 @@ class PublicFieldSpec:
@dataclass(frozen=True)
class CliOps:
generate_types: Callable[[], None]
stage_python_core_sdk_package: Callable[[Path, str], Path]
stage_python_sdk_package: Callable[[Path, str, str], Path]
stage_python_runtime_package: Callable[[Path, str, Path], Path]
current_sdk_version: Callable[[], str]
@@ -916,23 +968,37 @@ def build_parser() -> argparse.ArgumentParser:
"generate-types", help="Regenerate Python protocol-derived types"
)
stage_sdk_core_parser = subparsers.add_parser(
"stage-sdk-core",
help="Stage a releasable core SDK package without a bundled runtime",
)
stage_sdk_core_parser.add_argument(
"staging_dir",
type=Path,
help="Output directory for the staged core SDK package",
)
stage_sdk_core_parser.add_argument(
"--sdk-version",
help="Version to write into the staged core SDK package (defaults to sdk/python current version)",
)
stage_sdk_parser = subparsers.add_parser(
"stage-sdk",
help="Stage a releasable SDK package pinned to a runtime version",
help="Stage a releasable bundled SDK metapackage pinned to a runtime version",
)
stage_sdk_parser.add_argument(
"staging_dir",
type=Path,
help="Output directory for the staged SDK package",
help="Output directory for the staged bundled SDK package",
)
stage_sdk_parser.add_argument(
"--runtime-version",
required=True,
help="Pinned codex-cli-bin version for the staged SDK package",
help="Pinned codex-cli-bin version for the staged bundled SDK package",
)
stage_sdk_parser.add_argument(
"--sdk-version",
help="Version to write into the staged SDK package (defaults to sdk/python current version)",
help="Version to write into the staged bundled SDK package (defaults to sdk/python current version)",
)
stage_runtime_parser = subparsers.add_parser(
@@ -964,6 +1030,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
def default_cli_ops() -> CliOps:
return CliOps(
generate_types=generate_types,
stage_python_core_sdk_package=stage_python_core_sdk_package,
stage_python_sdk_package=stage_python_sdk_package,
stage_python_runtime_package=stage_python_runtime_package,
current_sdk_version=current_sdk_version,
@@ -973,6 +1040,12 @@ def default_cli_ops() -> CliOps:
def run_command(args: argparse.Namespace, ops: CliOps) -> None:
if args.command == "generate-types":
ops.generate_types()
elif args.command == "stage-sdk-core":
ops.generate_types()
ops.stage_python_core_sdk_package(
args.staging_dir,
args.sdk_version or ops.current_sdk_version(),
)
elif args.command == "stage-sdk":
ops.generate_types()
ops.stage_python_sdk_package(

View File

@@ -47,6 +47,7 @@ from .retry import retry_on_overload
ModelT = TypeVar("ModelT", bound=BaseModel)
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
BUNDLED_SDK_PKG_NAME = "codex-app-server-sdk"
RUNTIME_PKG_NAME = "codex-cli-bin"
@@ -82,9 +83,9 @@ def _installed_codex_path() -> Path:
from codex_cli_bin import bundled_codex_path
except ImportError as exc:
raise FileNotFoundError(
"Unable to locate the pinned Codex runtime. Install the published SDK build "
f"with its {RUNTIME_PKG_NAME} dependency, or set AppServerConfig.codex_bin "
"explicitly."
"Unable to locate the Codex runtime. Install the published "
f"{BUNDLED_SDK_PKG_NAME} package, install {RUNTIME_PKG_NAME} "
"alongside the core SDK, or set AppServerConfig.codex_bin explicitly."
) from exc
return bundled_codex_path()

View File

@@ -117,7 +117,7 @@ def test_python_codegen_schema_annotation_adds_stable_variant_titles() -> None:
]
assert ask_for_approval_titles == [
"AskForApprovalValue",
"RejectAskForApproval",
"GranularAskForApproval",
]
reasoning_summary_titles = [
@@ -238,17 +238,31 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) ->
assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n"
def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None:
def test_stage_core_sdk_release_sets_version_without_runtime_pin(tmp_path: Path) -> None:
script = _load_update_script_module()
staged = script.stage_python_core_sdk_package(tmp_path / "sdk-core-stage", "0.2.1")
pyproject = (staged / "pyproject.toml").read_text()
assert 'version = "0.2.1"' in pyproject
assert 'name = "codex-app-server-sdk-core"' in pyproject
assert "codex-cli-bin" not in pyproject
assert not any((staged / "src" / "codex_app_server").glob("bin/**"))
def test_stage_bundled_sdk_release_injects_exact_core_and_runtime_pins(
tmp_path: Path,
) -> None:
script = _load_update_script_module()
staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3")
pyproject = (staged / "pyproject.toml").read_text()
assert 'name = "codex-app-server-sdk"' in pyproject
assert 'version = "0.2.1"' in pyproject
assert '"codex-app-server-sdk-core==0.2.1"' in pyproject
assert '"codex-cli-bin==1.2.3"' in pyproject
assert not any((staged / "src" / "codex_app_server").glob("bin/**"))
def test_stage_sdk_release_replaces_existing_staging_dir(tmp_path: Path) -> None:
def test_stage_bundled_sdk_release_replaces_existing_staging_dir(tmp_path: Path) -> None:
script = _load_update_script_module()
staging_dir = tmp_path / "sdk-stage"
old_file = staging_dir / "stale.txt"
@@ -261,6 +275,49 @@ def test_stage_sdk_release_replaces_existing_staging_dir(tmp_path: Path) -> None
assert not old_file.exists()
def test_stage_sdk_core_runs_type_generation_before_staging(tmp_path: Path) -> None:
script = _load_update_script_module()
calls: list[str] = []
args = script.parse_args(
[
"stage-sdk-core",
str(tmp_path / "sdk-core-stage"),
]
)
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_core_sdk_package(_staging_dir: Path, _sdk_version: str) -> Path:
calls.append("stage_sdk_core")
return tmp_path / "sdk-core-stage"
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
raise AssertionError("bundled sdk staging should not run for stage-sdk-core")
def fake_stage_runtime_package(
_staging_dir: Path, _runtime_version: str, _runtime_binary: Path
) -> Path:
raise AssertionError("runtime staging should not run for stage-sdk-core")
def fake_current_sdk_version() -> str:
return "0.2.0"
ops = script.CliOps(
generate_types=fake_generate_types,
stage_python_core_sdk_package=fake_stage_core_sdk_package,
stage_python_sdk_package=fake_stage_sdk_package,
stage_python_runtime_package=fake_stage_runtime_package,
current_sdk_version=fake_current_sdk_version,
)
script.run_command(args, ops)
assert calls == ["generate_types", "stage_sdk_core"]
def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
script = _load_update_script_module()
calls: list[str] = []
@@ -276,6 +333,9 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_core_sdk_package(_staging_dir: Path, _sdk_version: str) -> Path:
raise AssertionError("core sdk staging should not run for stage-sdk")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
@@ -292,6 +352,7 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
ops = script.CliOps(
generate_types=fake_generate_types,
stage_python_core_sdk_package=fake_stage_core_sdk_package,
stage_python_sdk_package=fake_stage_sdk_package,
stage_python_runtime_package=fake_stage_runtime_package,
current_sdk_version=fake_current_sdk_version,
@@ -320,6 +381,9 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_core_sdk_package(_staging_dir: Path, _sdk_version: str) -> Path:
raise AssertionError("core sdk staging should not run for stage-runtime")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
@@ -336,6 +400,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
ops = script.CliOps(
generate_types=fake_generate_types,
stage_python_core_sdk_package=fake_stage_core_sdk_package,
stage_python_sdk_package=fake_stage_sdk_package,
stage_python_runtime_package=fake_stage_runtime_package,
current_sdk_version=fake_current_sdk_version,