Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Bolin
e7cf04363a execpolicy: add host_executable() support 2026-02-27 11:25:04 -08:00
299 changed files with 3507 additions and 33900 deletions

View File

@@ -28,17 +28,14 @@ jobs:
target: x86_64-apple-darwin
# Linux
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- os: ubuntu-24.04
target: x86_64-unknown-linux-musl
# 2026-02-27 Bazel tests have been flaky on arm in CI.
# Disable until we can investigate and stabilize them.
# - os: ubuntu-24.04-arm
# target: aarch64-unknown-linux-musl
# - os: ubuntu-24.04-arm
# target: aarch64-unknown-linux-gnu
# TODO: Enable Windows once we fix the toolchain issues there.
#- os: windows-latest
# target: x86_64-pc-windows-gnullvm

View File

@@ -7,17 +7,15 @@ on:
- labeled
jobs:
gather-duplicates-all:
name: Identify potential duplicates (all issues)
gather-duplicates:
name: Identify potential duplicates
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate'))
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
issues_json: ${{ steps.normalize-all.outputs.issues_json }}
reason: ${{ steps.normalize-all.outputs.reason }}
has_matches: ${{ steps.normalize-all.outputs.has_matches }}
codex_output: ${{ steps.select-final.outputs.codex_output }}
steps:
- uses: actions/checkout@v6
@@ -31,6 +29,7 @@ jobs:
CURRENT_ISSUE_FILE=codex-current-issue.json
EXISTING_ALL_FILE=codex-existing-issues-all.json
EXISTING_OPEN_FILE=codex-existing-issues-open.json
gh issue list --repo "$REPO" \
--json number,title,body,createdAt,updatedAt,state,labels \
@@ -48,6 +47,22 @@ jobs:
}]' \
> "$EXISTING_ALL_FILE"
gh issue list --repo "$REPO" \
--json number,title,body,createdAt,updatedAt,state,labels \
--limit 1000 \
--state open \
--search "sort:created-desc" \
| jq '[.[] | {
number,
title,
body: ((.body // "")[0:4000]),
createdAt,
updatedAt,
state,
labels: ((.labels // []) | map(.name))
}]' \
> "$EXISTING_OPEN_FILE"
gh issue view "$ISSUE_NUMBER" \
--repo "$REPO" \
--json number,title,body \
@@ -56,6 +71,7 @@ jobs:
echo "Prepared duplicate detection input files."
echo "all_issue_count=$(jq 'length' "$EXISTING_ALL_FILE")"
echo "open_issue_count=$(jq 'length' "$EXISTING_OPEN_FILE")"
# Prompt instructions are intentionally inline in this workflow. The old
# .github/prompts/issue-deduplicator.txt file is obsolete and removed.
@@ -142,59 +158,9 @@ jobs:
echo "has_matches=$has_matches"
} >> "$GITHUB_OUTPUT"
gather-duplicates-open:
name: Identify potential duplicates (open issues fallback)
# Pass 1 may drop sudo on the runner, so run the fallback in a fresh job.
needs: gather-duplicates-all
if: ${{ needs.gather-duplicates-all.result == 'success' && needs.gather-duplicates-all.outputs.has_matches != 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
issues_json: ${{ steps.normalize-open.outputs.issues_json }}
reason: ${{ steps.normalize-open.outputs.reason }}
has_matches: ${{ steps.normalize-open.outputs.has_matches }}
steps:
- uses: actions/checkout@v6
- name: Prepare Codex inputs
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -eo pipefail
CURRENT_ISSUE_FILE=codex-current-issue.json
EXISTING_OPEN_FILE=codex-existing-issues-open.json
gh issue list --repo "$REPO" \
--json number,title,body,createdAt,updatedAt,state,labels \
--limit 1000 \
--state open \
--search "sort:created-desc" \
| jq '[.[] | {
number,
title,
body: ((.body // "")[0:4000]),
createdAt,
updatedAt,
state,
labels: ((.labels // []) | map(.name))
}]' \
> "$EXISTING_OPEN_FILE"
gh issue view "$ISSUE_NUMBER" \
--repo "$REPO" \
--json number,title,body \
| jq '{number, title, body: ((.body // "")[0:4000])}' \
> "$CURRENT_ISSUE_FILE"
echo "Prepared fallback duplicate detection input files."
echo "open_issue_count=$(jq 'length' "$EXISTING_OPEN_FILE")"
- id: codex-open
name: Find duplicates (pass 2, open issues)
if: ${{ steps.normalize-all.outputs.has_matches != 'true' }}
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
@@ -234,6 +200,7 @@ jobs:
- id: normalize-open
name: Normalize pass 2 output
if: ${{ steps.normalize-all.outputs.has_matches != 'true' }}
env:
CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }}
CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
@@ -276,27 +243,15 @@ jobs:
echo "has_matches=$has_matches"
} >> "$GITHUB_OUTPUT"
select-final:
name: Select final duplicate set
needs:
- gather-duplicates-all
- gather-duplicates-open
if: ${{ always() && needs.gather-duplicates-all.result == 'success' && (needs.gather-duplicates-open.result == 'success' || needs.gather-duplicates-open.result == 'skipped') }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
codex_output: ${{ steps.select-final.outputs.codex_output }}
steps:
- id: select-final
name: Select final duplicate set
env:
PASS1_ISSUES: ${{ needs.gather-duplicates-all.outputs.issues_json }}
PASS1_REASON: ${{ needs.gather-duplicates-all.outputs.reason }}
PASS2_ISSUES: ${{ needs.gather-duplicates-open.outputs.issues_json }}
PASS2_REASON: ${{ needs.gather-duplicates-open.outputs.reason }}
PASS1_HAS_MATCHES: ${{ needs.gather-duplicates-all.outputs.has_matches }}
PASS2_HAS_MATCHES: ${{ needs.gather-duplicates-open.outputs.has_matches }}
PASS1_ISSUES: ${{ steps.normalize-all.outputs.issues_json }}
PASS1_REASON: ${{ steps.normalize-all.outputs.reason }}
PASS2_ISSUES: ${{ steps.normalize-open.outputs.issues_json }}
PASS2_REASON: ${{ steps.normalize-open.outputs.reason }}
PASS1_HAS_MATCHES: ${{ steps.normalize-all.outputs.has_matches }}
PASS2_HAS_MATCHES: ${{ steps.normalize-open.outputs.has_matches }}
run: |
set -eo pipefail
@@ -334,8 +289,8 @@ jobs:
comment-on-issue:
name: Comment with potential duplicates
needs: select-final
if: ${{ needs.select-final.result != 'skipped' }}
needs: gather-duplicates
if: ${{ needs.gather-duplicates.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -344,7 +299,7 @@ jobs:
- name: Comment on issue
uses: actions/github-script@v8
env:
CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }}
CODEX_OUTPUT: ${{ needs.gather-duplicates.outputs.codex_output }}
with:
github-token: ${{ github.token }}
script: |

View File

@@ -146,8 +146,9 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
@@ -187,8 +188,9 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc

41
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

651
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,6 @@ members = [
"mcp-server",
"network-proxy",
"ollama",
"artifact-presentation",
"artifact-spreadsheet",
"process-hardening",
"protocol",
"rmcp-client",
@@ -111,8 +109,6 @@ codex-mcp-server = { path = "mcp-server" }
codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-artifact-presentation = { path = "artifact-presentation" }
codex-artifact-spreadsheet = { path = "artifact-spreadsheet" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
@@ -219,7 +215,6 @@ owo-colors = "4.3.0"
path-absolutize = "3.1.1"
pathdiff = "0.2"
portable-pty = "0.9.0"
ppt-rs = "0.2.6"
predicates = "3"
pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
@@ -354,7 +349,6 @@ ignored = [
"openssl-sys",
"codex-utils-readiness",
"codex-secrets",
"codex-artifact-spreadsheet"
]
[profile.release]

View File

@@ -20,7 +20,6 @@ codex-utils-absolute-path = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_with = { workspace = true }
shlex = { workspace = true }
strum_macros = { workspace = true }
thiserror = { workspace = true }

View File

@@ -1703,12 +1703,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
@@ -1939,23 +1933,6 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -2178,23 +2155,6 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -2339,23 +2299,6 @@
"string",
"null"
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -2466,24 +2409,6 @@
],
"description": "Override the sandbox policy for this turn and subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for this turn and subsequent turns."
},
"summary": {
"anyOf": [
{

View File

@@ -1138,16 +1138,6 @@
],
"description": "How to sandbox commands executed in the system"
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"session_id": {
"$ref": "#/definitions/ThreadId"
},
@@ -4244,14 +4234,8 @@
{
"additionalProperties": false,
"properties": {
"SessionUpdated": {
"SessionCreated": {
"properties": {
"instructions": {
"type": [
"string",
"null"
]
},
"session_id": {
"type": "string"
}
@@ -4262,6 +4246,27 @@
"type": "object"
}
},
"required": [
"SessionCreated"
],
"title": "SessionCreatedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"SessionUpdated": {
"properties": {
"backend_prompt": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"required": [
"SessionUpdated"
],
@@ -4292,40 +4297,6 @@
"title": "ConversationItemAddedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"ConversationItemDone": {
"properties": {
"item_id": {
"type": "string"
}
},
"required": [
"item_id"
],
"type": "object"
}
},
"required": [
"ConversationItemDone"
],
"title": "ConversationItemDoneRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"HandoffRequested": {
"$ref": "#/definitions/RealtimeHandoffRequested"
}
},
"required": [
"HandoffRequested"
],
"title": "HandoffRequestedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
@@ -4341,47 +4312,6 @@
}
]
},
"RealtimeHandoffMessage": {
"properties": {
"role": {
"type": "string"
},
"text": {
"type": "string"
}
},
"required": [
"role",
"text"
],
"type": "object"
},
"RealtimeHandoffRequested": {
"properties": {
"handoff_id": {
"type": "string"
},
"input_transcript": {
"type": "string"
},
"item_id": {
"type": "string"
},
"messages": {
"items": {
"$ref": "#/definitions/RealtimeHandoffMessage"
},
"type": "array"
}
},
"required": [
"handoff_id",
"input_transcript",
"item_id",
"messages"
],
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -5420,12 +5350,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionNetworkProxyRuntime": {
"properties": {
"admin_addr": {
@@ -6770,16 +6694,6 @@
],
"description": "How to sandbox commands executed in the system"
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"session_id": {
"$ref": "#/definitions/ThreadId"
},

View File

@@ -70,18 +70,7 @@
"method": {
"type": "string"
},
"params": true,
"trace": {
"anyOf": [
{
"$ref": "#/definitions/W3cTraceContext"
},
{
"type": "null"
}
],
"description": "Optional W3C Trace Context for distributed tracing."
}
"params": true
},
"required": [
"id",
@@ -113,23 +102,6 @@
"type": "integer"
}
]
},
"W3cTraceContext": {
"properties": {
"traceparent": {
"type": [
"string",
"null"
]
},
"tracestate": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.",

View File

@@ -11,23 +11,6 @@
"type": "integer"
}
]
},
"W3cTraceContext": {
"properties": {
"traceparent": {
"type": [
"string",
"null"
]
},
"tracestate": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"description": "A request that expects a response.",
@@ -38,18 +21,7 @@
"method": {
"type": "string"
},
"params": true,
"trace": {
"anyOf": [
{
"$ref": "#/definitions/W3cTraceContext"
},
{
"type": "null"
}
],
"description": "Optional W3C Trace Context for distributed tracing."
}
"params": true
},
"required": [
"id",

View File

@@ -46,16 +46,6 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/PlanType"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -1432,32 +1422,6 @@
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
"type": "string"
},
{
"format": "int64",
"type": "integer"
}
]
},
"ServerRequestResolvedNotification": {
"properties": {
"requestId": {
"$ref": "#/definitions/RequestId"
},
"threadId": {
"type": "string"
}
},
"required": [
"requestId",
"threadId"
],
"type": "object"
},
"SessionSource": {
"oneOf": [
{
@@ -1665,10 +1629,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -1738,7 +1698,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -3463,26 +3422,6 @@
"title": "Item/fileChange/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"serverRequest/resolved"
],
"title": "ServerRequest/resolvedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ServerRequestResolvedNotification"
}
},
"required": [
"method",
"params"
],
"title": "ServerRequest/resolvedNotification",
"type": "object"
},
{
"properties": {
"method": {

View File

@@ -357,7 +357,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -382,7 +382,7 @@
"description": "NEW APIs",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -406,7 +406,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -430,7 +430,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -454,7 +454,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -478,7 +478,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -502,7 +502,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -526,7 +526,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -550,7 +550,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -574,7 +574,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -598,7 +598,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -622,7 +622,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -646,7 +646,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -670,7 +670,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -694,7 +694,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -718,7 +718,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -742,7 +742,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -766,7 +766,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -790,7 +790,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -814,7 +814,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -838,7 +838,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -862,7 +862,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -886,7 +886,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -910,7 +910,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -934,7 +934,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -958,7 +958,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -981,7 +981,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1005,7 +1005,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1029,7 +1029,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1053,7 +1053,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1077,7 +1077,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1100,7 +1100,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1123,7 +1123,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1148,7 +1148,7 @@
"description": "Execute a command (argv vector) under the server's sandbox.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1172,7 +1172,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1196,7 +1196,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1220,7 +1220,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1244,7 +1244,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1268,7 +1268,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1292,7 +1292,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1315,7 +1315,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -1339,7 +1339,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -2306,16 +2306,6 @@
],
"description": "How to sandbox commands executed in the system"
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"session_id": {
"$ref": "#/definitions/v2/ThreadId"
},
@@ -3075,7 +3065,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"message": {
"type": "string"
@@ -4953,7 +4943,7 @@
"$ref": "#/definitions/JSONRPCErrorError"
},
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
}
},
"required": [
@@ -5021,23 +5011,12 @@
"description": "A request that expects a response.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"type": "string"
},
"params": true,
"trace": {
"anyOf": [
{
"$ref": "#/definitions/W3cTraceContext"
},
{
"type": "null"
}
],
"description": "Optional W3C Trace Context for distributed tracing."
}
"params": true
},
"required": [
"id",
@@ -5051,7 +5030,7 @@
"description": "A successful (non-error) response to a request.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"result": true
},
@@ -5464,14 +5443,8 @@
{
"additionalProperties": false,
"properties": {
"SessionUpdated": {
"SessionCreated": {
"properties": {
"instructions": {
"type": [
"string",
"null"
]
},
"session_id": {
"type": "string"
}
@@ -5482,6 +5455,27 @@
"type": "object"
}
},
"required": [
"SessionCreated"
],
"title": "SessionCreatedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"SessionUpdated": {
"properties": {
"backend_prompt": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"required": [
"SessionUpdated"
],
@@ -5512,40 +5506,6 @@
"title": "ConversationItemAddedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"ConversationItemDone": {
"properties": {
"item_id": {
"type": "string"
}
},
"required": [
"item_id"
],
"type": "object"
}
},
"required": [
"ConversationItemDone"
],
"title": "ConversationItemDoneRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"HandoffRequested": {
"$ref": "#/definitions/RealtimeHandoffRequested"
}
},
"required": [
"HandoffRequested"
],
"title": "HandoffRequestedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
@@ -5561,47 +5521,6 @@
}
]
},
"RealtimeHandoffMessage": {
"properties": {
"role": {
"type": "string"
},
"text": {
"type": "string"
}
},
"required": [
"role",
"text"
],
"type": "object"
},
"RealtimeHandoffRequested": {
"properties": {
"handoff_id": {
"type": "string"
},
"input_transcript": {
"type": "string"
},
"item_id": {
"type": "string"
},
"messages": {
"items": {
"$ref": "#/definitions/RealtimeHandoffMessage"
},
"type": "array"
}
},
"required": [
"handoff_id",
"input_transcript",
"item_id",
"messages"
],
"type": "object"
},
"RejectConfig": {
"properties": {
"mcp_elicitations": {
@@ -5625,7 +5544,6 @@
"type": "object"
},
"RequestId": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"type": "string"
@@ -5635,7 +5553,7 @@
"type": "integer"
}
],
"title": "RequestId"
"description": "ID of a request, which can be either a string or an integer."
},
"RequestUserInputQuestion": {
"properties": {
@@ -6276,26 +6194,6 @@
"title": "Item/fileChange/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"serverRequest/resolved"
],
"title": "ServerRequest/resolvedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ServerRequestResolvedNotification"
}
},
"required": [
"method",
"params"
],
"title": "ServerRequest/resolvedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -6749,7 +6647,7 @@
"description": "NEW APIs Sent when approval is requested for a specific command execution. This request is used for Turns started via turn/start.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -6774,7 +6672,7 @@
"description": "Sent when approval is requested for a specific file change. This request is used for Turns started via turn/start.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -6799,7 +6697,7 @@
"description": "EXPERIMENTAL - Request input from the user for a tool call.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -6824,7 +6722,7 @@
"description": "Execute a dynamic tool call on the client.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -6848,7 +6746,7 @@
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -6873,7 +6771,7 @@
"description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -6898,7 +6796,7 @@
"description": "Request to exec a command. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
@@ -7301,23 +7199,6 @@
}
]
},
"W3cTraceContext": {
"properties": {
"traceparent": {
"type": [
"string",
"null"
]
},
"tracestate": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"v2": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
@@ -7417,16 +7298,6 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/v2/PlanType"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
@@ -8605,16 +8476,6 @@
}
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"tools": {
"anyOf": [
{
@@ -10865,16 +10726,6 @@
}
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -11259,17 +11110,6 @@
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
"type": "string"
},
{
"format": "int64",
"type": "integer"
}
]
},
"ResidencyRequirement": {
"enum": [
"us"
@@ -12009,29 +11849,6 @@
},
"type": "object"
},
"ServerRequestResolvedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"requestId": {
"$ref": "#/definitions/v2/RequestId"
},
"threadId": {
"type": "string"
}
},
"required": [
"requestId",
"threadId"
],
"title": "ServerRequestResolvedNotification",
"type": "object"
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -12632,10 +12449,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -12705,7 +12518,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -12847,23 +12659,6 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -12902,16 +12697,6 @@
"sandbox": {
"$ref": "#/definitions/v2/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -13857,23 +13642,6 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -13912,16 +13680,6 @@
"sandbox": {
"$ref": "#/definitions/v2/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -14100,23 +13858,6 @@
"string",
"null"
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
}
},
"title": "ThreadStartParams",
@@ -14150,16 +13891,6 @@
"sandbox": {
"$ref": "#/definitions/v2/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -14727,24 +14458,6 @@
],
"description": "Override the sandbox policy for this turn and subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for this turn and subsequent turns."
},
"summary": {
"anyOf": [
{

View File

@@ -26,20 +26,6 @@
"type": "string"
}
]
},
"PlanType": {
"enum": [
"free",
"go",
"plus",
"pro",
"team",
"business",
"enterprise",
"edu",
"unknown"
],
"type": "string"
}
},
"properties": {
@@ -52,16 +38,6 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/PlanType"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",

View File

@@ -323,16 +323,6 @@
}
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"tools": {
"anyOf": [
{
@@ -618,16 +608,6 @@
}
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -705,12 +685,6 @@
},
"type": "object"
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"ToolsV2": {
"properties": {
"view_image": {

View File

@@ -1,30 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RequestId": {
"anyOf": [
{
"type": "string"
},
{
"format": "int64",
"type": "integer"
}
]
}
},
"properties": {
"requestId": {
"$ref": "#/definitions/RequestId"
},
"threadId": {
"type": "string"
}
},
"required": [
"requestId",
"threadId"
],
"title": "ServerRequestResolvedNotification",
"type": "object"
}

View File

@@ -50,12 +50,6 @@
"danger-full-access"
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
}
},
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
@@ -118,23 +112,6 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}

View File

@@ -738,12 +738,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -888,10 +882,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -961,7 +951,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -1912,16 +1901,6 @@
"sandbox": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/Thread"
}

View File

@@ -655,10 +655,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -728,7 +724,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -655,10 +655,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -728,7 +724,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -738,12 +738,6 @@
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"WebSearchAction": {
"oneOf": [
{
@@ -916,23 +910,6 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}

View File

@@ -738,12 +738,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -888,10 +882,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -961,7 +951,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -1912,16 +1901,6 @@
"sandbox": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/Thread"
}

View File

@@ -655,10 +655,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -728,7 +724,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -75,12 +75,6 @@
"danger-full-access"
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
}
},
"properties": {
@@ -162,23 +156,6 @@
"string",
"null"
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
}
},
"title": "ThreadStartParams",

View File

@@ -738,12 +738,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -888,10 +882,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -961,7 +951,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -1912,16 +1901,6 @@
"sandbox": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/Thread"
}

View File

@@ -655,10 +655,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -728,7 +724,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -655,10 +655,6 @@
"description": "Working directory captured for the thread.",
"type": "string"
},
"ephemeral": {
"description": "Whether the thread is ephemeral and should not be materialized on disk.",
"type": "boolean"
},
"gitInfo": {
"anyOf": [
{
@@ -728,7 +724,6 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -299,12 +299,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
@@ -545,24 +539,6 @@
],
"description": "Override the sandbox policy for this turn and subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for this turn and subsequent turns."
},
"summary": {
"anyOf": [
{

View File

@@ -2,7 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RealtimeAudioFrame } from "./RealtimeAudioFrame";
import type { RealtimeHandoffRequested } from "./RealtimeHandoffRequested";
import type { JsonValue } from "./serde_json/JsonValue";
export type RealtimeEvent = { "SessionUpdated": { session_id: string, instructions: string | null, } } | { "AudioOut": RealtimeAudioFrame } | { "ConversationItemAdded": JsonValue } | { "ConversationItemDone": { item_id: string, } } | { "HandoffRequested": RealtimeHandoffRequested } | { "Error": string };
export type RealtimeEvent = { "SessionCreated": { session_id: string, } } | { "SessionUpdated": { backend_prompt: string | null, } } | { "AudioOut": RealtimeAudioFrame } | { "ConversationItemAdded": JsonValue } | { "Error": string };

View File

@@ -1,5 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RealtimeHandoffMessage = { role: string, text: string, };

View File

@@ -1,6 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RealtimeHandoffMessage } from "./RealtimeHandoffMessage";
export type RealtimeHandoffRequested = { handoff_id: string, item_id: string, input_transcript: string, messages: Array<RealtimeHandoffMessage>, };

View File

@@ -6,11 +6,10 @@ import type { InputItem } from "./InputItem";
import type { ReasoningEffort } from "./ReasoningEffort";
import type { ReasoningSummary } from "./ReasoningSummary";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { ServiceTier } from "./ServiceTier";
import type { ThreadId } from "./ThreadId";
import type { JsonValue } from "./serde_json/JsonValue";
export type SendUserTurnParams = { conversationId: ThreadId, items: Array<InputItem>, cwd: string, approvalPolicy: AskForApproval, sandboxPolicy: SandboxPolicy, model: string, serviceTier?: ServiceTier | null | null, effort: ReasoningEffort | null, summary: ReasoningSummary,
export type SendUserTurnParams = { conversationId: ThreadId, items: Array<InputItem>, cwd: string, approvalPolicy: AskForApproval, sandboxPolicy: SandboxPolicy, model: string, effort: ReasoningEffort | null, summary: ReasoningSummary,
/**
* Optional JSON Schema used to constrain the final assistant message for this turn.
*/

View File

@@ -27,7 +27,6 @@ import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemC
import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification";
import type { ReasoningSummaryTextDeltaNotification } from "./v2/ReasoningSummaryTextDeltaNotification";
import type { ReasoningTextDeltaNotification } from "./v2/ReasoningTextDeltaNotification";
import type { ServerRequestResolvedNotification } from "./v2/ServerRequestResolvedNotification";
import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification";
import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification";
import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification";
@@ -51,4 +50,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification };

View File

@@ -1,5 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ServiceTier = "fast";

View File

@@ -5,7 +5,6 @@ import type { AskForApproval } from "./AskForApproval";
import type { EventMsg } from "./EventMsg";
import type { ReasoningEffort } from "./ReasoningEffort";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { ServiceTier } from "./ServiceTier";
import type { SessionNetworkProxyRuntime } from "./SessionNetworkProxyRuntime";
import type { ThreadId } from "./ThreadId";
@@ -17,7 +16,7 @@ thread_name?: string,
/**
* Tell the client what model is being queried.
*/
model: string, model_provider_id: string, service_tier: ServiceTier | null,
model: string, model_provider_id: string,
/**
* When to escalate for approval for execution
*/

View File

@@ -3,7 +3,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { EventMsg } from "./EventMsg";
import type { ReasoningEffort } from "./ReasoningEffort";
import type { ServiceTier } from "./ServiceTier";
import type { ThreadId } from "./ThreadId";
export type SessionConfiguredNotification = { sessionId: ThreadId, model: string, serviceTier: ServiceTier | null, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array<EventMsg> | null, rolloutPath: string, };
export type SessionConfiguredNotification = { sessionId: ThreadId, model: string, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array<EventMsg> | null, rolloutPath: string, };

View File

@@ -161,8 +161,6 @@ export type { RealtimeConversationClosedEvent } from "./RealtimeConversationClos
export type { RealtimeConversationRealtimeEvent } from "./RealtimeConversationRealtimeEvent";
export type { RealtimeConversationStartedEvent } from "./RealtimeConversationStartedEvent";
export type { RealtimeEvent } from "./RealtimeEvent";
export type { RealtimeHandoffMessage } from "./RealtimeHandoffMessage";
export type { RealtimeHandoffRequested } from "./RealtimeHandoffRequested";
export type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent";
export type { ReasoningEffort } from "./ReasoningEffort";
export type { ReasoningItem } from "./ReasoningItem";
@@ -200,7 +198,6 @@ export type { SendUserTurnParams } from "./SendUserTurnParams";
export type { SendUserTurnResponse } from "./SendUserTurnResponse";
export type { ServerNotification } from "./ServerNotification";
export type { ServerRequest } from "./ServerRequest";
export type { ServiceTier } from "./ServiceTier";
export type { SessionConfiguredEvent } from "./SessionConfiguredEvent";
export type { SessionConfiguredNotification } from "./SessionConfiguredNotification";
export type { SessionNetworkProxyRuntime } from "./SessionNetworkProxyRuntime";

View File

@@ -2,6 +2,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthMode } from "../AuthMode";
import type { PlanType } from "../PlanType";
export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, };
export type AccountUpdatedNotification = { authMode: AuthMode | null, };

View File

@@ -4,7 +4,6 @@
import type { ForcedLoginMethod } from "../ForcedLoginMethod";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ServiceTier } from "../ServiceTier";
import type { Verbosity } from "../Verbosity";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
@@ -15,4 +14,4 @@ import type { SandboxMode } from "./SandboxMode";
import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";
import type { ToolsV2 } from "./ToolsV2";
export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });

View File

@@ -3,10 +3,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ServiceTier } from "../ServiceTier";
import type { Verbosity } from "../Verbosity";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });

View File

@@ -1,6 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RequestId } from "../RequestId";
export type ServerRequestResolvedNotification = { threadId: string, requestId: RequestId, };

View File

@@ -11,10 +11,6 @@ export type Thread = { id: string,
* Usually the first user message in the thread, if available.
*/
preview: string,
/**
* Whether the thread is ephemeral and should not be materialized on disk.
*/
ephemeral: boolean,
/**
* Model provider used for this thread (for example, 'openai').
*/

View File

@@ -1,7 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
@@ -22,7 +21,7 @@ export type ThreadForkParams = {threadId: string, /**
path?: string | null, /**
* Configuration overrides for the forked thread, if any.
*/
model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /**
model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/

View File

@@ -2,9 +2,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };

View File

@@ -3,7 +3,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Personality } from "../Personality";
import type { ResponseItem } from "../ResponseItem";
import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
@@ -31,7 +30,7 @@ history?: Array<ResponseItem> | null, /**
path?: string | null, /**
* Configuration overrides for the resumed thread, if any.
*/
model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /**
model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/

View File

@@ -2,9 +2,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };

View File

@@ -2,12 +2,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Personality } from "../Personality";
import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
* If true, opt into emitting raw Responses API items on the event stream.
* This is for internal use only (e.g. Codex Cloud).
*/

View File

@@ -2,9 +2,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };

View File

@@ -5,7 +5,6 @@ import type { CollaborationMode } from "../CollaborationMode";
import type { Personality } from "../Personality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
@@ -24,9 +23,6 @@ sandboxPolicy?: SandboxPolicy | null, /**
* Override the model for this turn and subsequent turns.
*/
model?: string | null, /**
* Override the service tier for this turn and subsequent turns.
*/
serviceTier?: ServiceTier | null | null, /**
* Override the reasoning effort for this turn and subsequent turns.
*/
effort?: ReasoningEffort | null, /**

View File

@@ -142,7 +142,6 @@ export type { ReviewTarget } from "./ReviewTarget";
export type { SandboxMode } from "./SandboxMode";
export type { SandboxPolicy } from "./SandboxPolicy";
export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";
export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification";
export type { SessionSource } from "./SessionSource";
export type { SkillDependencies } from "./SkillDependencies";
export type { SkillErrorInfo } from "./SkillErrorInfo";

View File

@@ -1,7 +1,6 @@
//! We do not do true JSON-RPC 2.0, as we neither send nor expect the
//! "jsonrpc": "2.0" field.
use codex_protocol::protocol::W3cTraceContext;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -9,9 +8,7 @@ use ts_rs::TS;
pub const JSONRPC_VERSION: &str = "2.0";
#[derive(
Debug, Clone, PartialEq, PartialOrd, Ord, Deserialize, Serialize, Hash, Eq, JsonSchema, TS,
)]
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)]
#[serde(untagged)]
pub enum RequestId {
String(String),
@@ -39,10 +36,6 @@ pub struct JSONRPCRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub params: Option<serde_json::Value>,
/// Optional W3C Trace Context for distributed tracing.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub trace: Option<W3cTraceContext>,
}
/// A notification which does not expect a response.

View File

@@ -548,14 +548,6 @@ macro_rules! server_request_definitions {
)*
}
impl ServerRequest {
pub fn id(&self) -> &RequestId {
match self {
$(Self::$variant { request_id, .. } => request_id,)*
}
}
}
#[derive(Debug, Clone, PartialEq, JsonSchema)]
#[allow(clippy::large_enum_variant)]
pub enum ServerRequestPayload {
@@ -846,7 +838,6 @@ server_notification_definitions! {
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
ServerRequestResolved => "serverRequest/resolved" (v2::ServerRequestResolvedNotification),
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification),
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
@@ -1115,7 +1106,6 @@ mod tests {
);
let payload = ServerRequestPayload::ExecCommandApproval(params);
assert_eq!(request.id(), &RequestId::Integer(7));
assert_eq!(payload.request_with_id(RequestId::Integer(7)), request);
Ok(())
}

View File

@@ -3,7 +3,6 @@
pub mod common;
mod mappers;
mod serde_helpers;
pub mod thread_history;
pub mod v1;
pub mod v2;

View File

@@ -1,23 +0,0 @@
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
pub fn deserialize_double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
serde_with::rust::double_option::deserialize(deserializer)
}
pub fn serialize_double_option<T, S>(
value: &Option<Option<T>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
T: Serialize,
S: Serializer,
{
serde_with::rust::double_option::serialize(value, serializer)
}

View File

@@ -22,7 +22,6 @@ use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ContextCompactedEvent;
use codex_protocol::protocol::DynamicToolCallResponseEvent;
@@ -127,9 +126,6 @@ impl ThreadHistoryBuilder {
EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload),
EventMsg::ExecCommandBegin(payload) => self.handle_exec_command_begin(payload),
EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload),
EventMsg::ApplyPatchApprovalRequest(payload) => {
self.handle_apply_patch_approval_request(payload)
}
EventMsg::PatchApplyBegin(payload) => self.handle_patch_apply_begin(payload),
EventMsg::PatchApplyEnd(payload) => self.handle_patch_apply_end(payload),
EventMsg::DynamicToolCallRequest(payload) => {
@@ -368,19 +364,6 @@ impl ThreadHistoryBuilder {
self.upsert_item_in_turn_id(&payload.turn_id, item);
}
fn handle_apply_patch_approval_request(&mut self, payload: &ApplyPatchApprovalRequestEvent) {
let item = ThreadItem::FileChange {
id: payload.call_id.clone(),
changes: convert_patch_changes(&payload.changes),
status: PatchApplyStatus::InProgress,
};
if payload.turn_id.is_empty() {
self.upsert_item_in_current_turn(item);
} else {
self.upsert_item_in_turn_id(&payload.turn_id, item);
}
}
fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) {
let item = ThreadItem::FileChange {
id: payload.call_id.clone(),
@@ -1097,7 +1080,6 @@ mod tests {
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::DynamicToolCallResponseEvent;
@@ -1106,7 +1088,6 @@ mod tests {
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
@@ -1999,133 +1980,6 @@ mod tests {
);
}
#[test]
fn patch_apply_begin_updates_active_turn_snapshot_with_file_change() {
let turn_id = "turn-1";
let mut builder = ThreadHistoryBuilder::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "apply patch".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: "patch-call".into(),
turn_id: turn_id.to_string(),
auto_approved: false,
changes: [(
PathBuf::from("README.md"),
codex_protocol::protocol::FileChange::Add {
content: "hello\n".into(),
},
)]
.into_iter()
.collect(),
}),
];
for event in &events {
builder.handle_event(event);
}
let snapshot = builder
.active_turn_snapshot()
.expect("active turn snapshot");
assert_eq!(snapshot.id, turn_id);
assert_eq!(snapshot.status, TurnStatus::InProgress);
assert_eq!(
snapshot.items,
vec![
ThreadItem::UserMessage {
id: "item-1".into(),
content: vec![UserInput::Text {
text: "apply patch".into(),
text_elements: Vec::new(),
}],
},
ThreadItem::FileChange {
id: "patch-call".into(),
changes: vec![FileUpdateChange {
path: "README.md".into(),
kind: PatchChangeKind::Add,
diff: "hello\n".into(),
}],
status: PatchApplyStatus::InProgress,
},
]
);
}
#[test]
fn apply_patch_approval_request_updates_active_turn_snapshot_with_file_change() {
let turn_id = "turn-1";
let mut builder = ThreadHistoryBuilder::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "apply patch".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: "patch-call".into(),
turn_id: turn_id.to_string(),
changes: [(
PathBuf::from("README.md"),
codex_protocol::protocol::FileChange::Add {
content: "hello\n".into(),
},
)]
.into_iter()
.collect(),
reason: None,
grant_root: None,
}),
];
for event in &events {
builder.handle_event(event);
}
let snapshot = builder
.active_turn_snapshot()
.expect("active turn snapshot");
assert_eq!(snapshot.id, turn_id);
assert_eq!(snapshot.status, TurnStatus::InProgress);
assert_eq!(
snapshot.items,
vec![
ThreadItem::UserMessage {
id: "item-1".into(),
content: vec![UserInput::Text {
text: "apply patch".into(),
text_elements: Vec::new(),
}],
},
ThreadItem::FileChange {
id: "patch-call".into(),
changes: vec![FileUpdateChange {
path: "README.md".into(),
kind: PatchChangeKind::Add,
diff: "hello\n".into(),
}],
status: PatchApplyStatus::InProgress,
},
]
);
}
#[test]
fn late_turn_complete_does_not_close_active_turn() {
let events = vec![

View File

@@ -5,7 +5,6 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
@@ -420,13 +419,6 @@ pub struct SendUserTurnParams {
pub approval_policy: AskForApproval,
pub sandbox_policy: SandboxPolicy,
pub model: String,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
pub service_tier: Option<Option<ServiceTier>>,
pub effort: Option<ReasoningEffort>,
pub summary: ReasoningSummary,
/// Optional JSON Schema used to constrain the final assistant message for this turn.
@@ -437,55 +429,6 @@ pub struct SendUserTurnParams {
#[serde(rename_all = "camelCase")]
pub struct SendUserTurnResponse {}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn send_user_turn_params_preserve_explicit_null_service_tier() {
let params = SendUserTurnParams {
conversation_id: ThreadId::new(),
items: vec![],
cwd: PathBuf::from("/tmp"),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-4.1".to_string(),
service_tier: Some(None),
effort: None,
summary: ReasoningSummary::Auto,
output_schema: None,
};
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("serviceTier"),
Some(&serde_json::Value::Null)
);
let roundtrip: SendUserTurnParams =
serde_json::from_value(serialized).expect("params should deserialize");
assert_eq!(roundtrip.service_tier, Some(None));
let without_override = SendUserTurnParams {
conversation_id: ThreadId::new(),
items: vec![],
cwd: PathBuf::from("/tmp"),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-4.1".to_string(),
service_tier: None,
effort: None,
summary: ReasoningSummary::Auto,
output_schema: None,
};
let serialized_without_override =
serde_json::to_value(&without_override).expect("params should serialize");
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InterruptConversationParams {
@@ -612,7 +555,6 @@ pub struct LoginChatGptCompleteNotification {
pub struct SessionConfiguredNotification {
pub session_id: ThreadId,
pub model: String,
pub service_tier: Option<ServiceTier>,
pub reasoning_effort: Option<ReasoningEffort>,
pub history_log_id: u64,
#[ts(type = "number")]

View File

@@ -1,7 +1,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::RequestId;
use crate::protocol::common::AuthMode;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::account::PlanType;
@@ -17,7 +16,6 @@ use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode as CoreSandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
@@ -393,7 +391,6 @@ pub struct ProfileV2 {
pub model: Option<String>,
pub model_provider: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub service_tier: Option<ServiceTier>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
@@ -505,7 +502,6 @@ pub struct Config {
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub service_tier: Option<ServiceTier>,
pub analytics: Option<AnalyticsConfig>,
#[experimental("config/read.apps")]
#[serde(default)]
@@ -1791,14 +1787,6 @@ pub struct ThreadStartParams {
pub model: Option<String>,
#[ts(optional = nullable)]
pub model_provider: Option<String>,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
#[ts(optional = nullable)]
@@ -1861,7 +1849,6 @@ pub struct ThreadStartResponse {
pub thread: Thread,
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
pub approval_policy: AskForApproval,
pub sandbox: SandboxPolicy,
@@ -1903,14 +1890,6 @@ pub struct ThreadResumeParams {
pub model: Option<String>,
#[ts(optional = nullable)]
pub model_provider: Option<String>,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
#[ts(optional = nullable)]
@@ -1939,7 +1918,6 @@ pub struct ThreadResumeResponse {
pub thread: Thread,
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
pub approval_policy: AskForApproval,
pub sandbox: SandboxPolicy,
@@ -1972,14 +1950,6 @@ pub struct ThreadForkParams {
pub model: Option<String>,
#[ts(optional = nullable)]
pub model_provider: Option<String>,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
#[ts(optional = nullable)]
@@ -2006,7 +1976,6 @@ pub struct ThreadForkResponse {
pub thread: Thread,
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
pub approval_policy: AskForApproval,
pub sandbox: SandboxPolicy,
@@ -2547,8 +2516,6 @@ pub struct Thread {
pub id: String,
/// Usually the first user message in the thread, if available.
pub preview: String,
/// Whether the thread is ephemeral and should not be materialized on disk.
pub ephemeral: bool,
/// Model provider used for this thread (for example, 'openai').
pub model_provider: String,
/// Unix timestamp (in seconds) when the thread was created.
@@ -2587,7 +2554,6 @@ pub struct Thread {
#[ts(export_to = "v2/")]
pub struct AccountUpdatedNotification {
pub auth_mode: Option<AuthMode>,
pub plan_type: Option<PlanType>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -2867,15 +2833,6 @@ pub struct TurnStartParams {
/// Override the model for this turn and subsequent turns.
#[ts(optional = nullable)]
pub model: Option<String>,
/// Override the service tier for this turn and subsequent turns.
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
/// Override the reasoning effort for this turn and subsequent turns.
#[ts(optional = nullable)]
pub effort: Option<ReasoningEffort>,
@@ -3788,14 +3745,6 @@ pub struct FileChangeOutputDeltaNotification {
pub delta: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ServerRequestResolvedNotification {
pub thread_id: String,
pub request_id: RequestId,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -4605,56 +4554,4 @@ mod tests {
})
);
}
#[test]
fn thread_start_params_preserve_explicit_null_service_tier() {
let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null }))
.expect("params should deserialize");
assert_eq!(params.service_tier, Some(None));
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("serviceTier"),
Some(&serde_json::Value::Null)
);
let serialized_without_override =
serde_json::to_value(ThreadStartParams::default()).expect("params should serialize");
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn turn_start_params_preserve_explicit_null_service_tier() {
let params: TurnStartParams = serde_json::from_value(json!({
"threadId": "thread_123",
"input": [],
"serviceTier": null
}))
.expect("params should deserialize");
assert_eq!(params.service_tier, Some(None));
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("serviceTier"),
Some(&serde_json::Value::Null)
);
let without_override = TurnStartParams {
thread_id: "thread_123".to_string(),
input: vec![],
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
service_tier: None,
effort: None,
summary: None,
output_schema: None,
collaboration_mode: None,
personality: None,
};
let serialized_without_override =
serde_json::to_value(&without_override).expect("params should serialize");
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
}

View File

@@ -18,15 +18,6 @@ cargo run -p codex-app-server-test-client -- \
cargo run -p codex-app-server-test-client -- model-list
```
## Watching Raw Inbound Traffic
Initialize a connection, then print every inbound JSON-RPC message until you stop it with
`Ctrl+C`:
```bash
cargo run -p codex-app-server-test-client -- watch
```
## Testing Thread Rejoin Behavior
Build and start an app server using commands above. The app-server log is written to `/tmp/codex-app-server-test-client/app-server.log`

View File

@@ -15,7 +15,6 @@ use std::process::Command;
use std::process::Stdio;
use std::thread;
use std::time::Duration;
use std::time::SystemTime;
use anyhow::Context;
use anyhow::Result;
@@ -72,7 +71,6 @@ use codex_app_server_protocol::UserInput as V2UserInput;
use codex_protocol::ThreadId;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::W3cTraceContext;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
@@ -106,8 +104,6 @@ const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
"item/reasoning/summaryTextDelta",
"item/reasoning/textDelta",
];
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
const APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
/// Minimal launcher that initializes the Codex app-server and logs the handshake.
#[derive(Parser)]
@@ -192,10 +188,6 @@ enum CliCommand {
/// Existing thread id to resume.
thread_id: String,
},
/// Initialize the app-server and dump all inbound messages until interrupted.
///
/// This command does not auto-exit; stop it with SIGINT/SIGTERM/SIGKILL.
Watch,
/// Start a V2 turn that elicits an ExecCommand approval.
#[command(name = "trigger-cmd-approval")]
TriggerCmdApproval {
@@ -299,11 +291,6 @@ pub fn run() -> Result<()> {
let endpoint = resolve_endpoint(codex_bin, url)?;
thread_resume_follow(&endpoint, &config_overrides, thread_id)
}
CliCommand::Watch => {
ensure_dynamic_tools_unused(&dynamic_tools, "watch")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
watch(&endpoint, &config_overrides)
}
CliCommand::TriggerCmdApproval { user_message } => {
let endpoint = resolve_endpoint(codex_bin, url)?;
trigger_cmd_approval(&endpoint, &config_overrides, user_message, &dynamic_tools)
@@ -502,26 +489,25 @@ fn send_message(
config_overrides: &[String],
user_message: String,
) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let conversation = client.start_thread()?;
println!("< newConversation response: {conversation:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let subscription = client.add_conversation_listener(&conversation.conversation_id)?;
println!("< addConversationListener response: {subscription:?}");
let conversation = client.start_thread()?;
println!("< newConversation response: {conversation:?}");
let send_response =
client.send_user_message(&conversation.conversation_id, &user_message)?;
println!("< sendUserMessage response: {send_response:?}");
let subscription = client.add_conversation_listener(&conversation.conversation_id)?;
println!("< addConversationListener response: {subscription:?}");
client.stream_conversation(&conversation.conversation_id)?;
let send_response = client.send_user_message(&conversation.conversation_id, &user_message)?;
println!("< sendUserMessage response: {send_response:?}");
client.remove_thread_listener(subscription.subscription_id)?;
client.stream_conversation(&conversation.conversation_id)?;
Ok(())
})
client.remove_thread_listener(subscription.subscription_id)?;
Ok(())
}
pub fn send_message_v2(
@@ -579,85 +565,82 @@ fn trigger_zsh_fork_multi_cmd_approval(
let default_prompt = "Run this exact command using shell command execution without rewriting or splitting it: /usr/bin/true && /usr/bin/true";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
client.command_approval_behavior = match abort_on {
Some(index) => CommandApprovalBehavior::AbortOn(index),
None => CommandApprovalBehavior::AlwaysAccept,
};
client.command_approval_count = 0;
client.command_approval_item_ids.clear();
client.command_execution_statuses.clear();
client.last_turn_status = None;
client.command_approval_behavior = match abort_on {
Some(index) => CommandApprovalBehavior::AbortOn(index),
None => CommandApprovalBehavior::AlwaysAccept,
};
client.command_approval_count = 0;
client.command_approval_item_ids.clear();
client.command_execution_statuses.clear();
client.last_turn_status = None;
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: message,
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = Some(AskForApproval::OnRequest);
turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
});
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: message,
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = Some(AskForApproval::OnRequest);
turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
});
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
if client.command_approval_count < min_approvals {
bail!(
"expected at least {min_approvals} command approvals, got {}",
client.command_approval_count
);
}
let mut approvals_per_item = std::collections::BTreeMap::new();
for item_id in &client.command_approval_item_ids {
*approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1;
}
let max_approvals_for_one_item = approvals_per_item.values().copied().max().unwrap_or(0);
if max_approvals_for_one_item < min_approvals {
bail!(
"expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}"
);
}
let last_command_status = client.command_execution_statuses.last();
if abort_on.is_none() {
if last_command_status != Some(&CommandExecutionStatus::Completed) {
bail!("expected completed command execution, got {last_command_status:?}");
}
if client.last_turn_status != Some(TurnStatus::Completed) {
bail!(
"expected completed turn in all-accept flow, got {:?}",
client.last_turn_status
);
}
} else if last_command_status == Some(&CommandExecutionStatus::Completed) {
bail!(
"expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}"
);
}
println!(
"[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}",
client.command_approval_count,
client.command_execution_statuses,
client.last_turn_status
if client.command_approval_count < min_approvals {
bail!(
"expected at least {min_approvals} command approvals, got {}",
client.command_approval_count
);
}
let mut approvals_per_item = std::collections::BTreeMap::new();
for item_id in &client.command_approval_item_ids {
*approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1;
}
let max_approvals_for_one_item = approvals_per_item.values().copied().max().unwrap_or(0);
if max_approvals_for_one_item < min_approvals {
bail!(
"expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}"
);
}
Ok(())
})
let last_command_status = client.command_execution_statuses.last();
if abort_on.is_none() {
if last_command_status != Some(&CommandExecutionStatus::Completed) {
bail!("expected completed command execution, got {last_command_status:?}");
}
if client.last_turn_status != Some(TurnStatus::Completed) {
bail!(
"expected completed turn in all-accept flow, got {:?}",
client.last_turn_status
);
}
} else if last_command_status == Some(&CommandExecutionStatus::Completed) {
bail!(
"expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}"
);
}
println!(
"[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}",
client.command_approval_count, client.command_execution_statuses, client.last_turn_status
);
Ok(())
}
fn resume_message_v2(
@@ -669,30 +652,30 @@ fn resume_message_v2(
) -> Result<()> {
ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let resume_response = client.thread_resume(ThreadResumeParams {
thread_id,
..Default::default()
})?;
println!("< thread/resume response: {resume_response:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let turn_response = client.turn_start(TurnStartParams {
thread_id: resume_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: user_message,
text_elements: Vec::new(),
}],
..Default::default()
})?;
println!("< turn/start response: {turn_response:?}");
let resume_response = client.thread_resume(ThreadResumeParams {
thread_id,
..Default::default()
})?;
println!("< thread/resume response: {resume_response:?}");
client.stream_turn(&resume_response.thread.id, &turn_response.turn.id)?;
let turn_response = client.turn_start(TurnStartParams {
thread_id: resume_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: user_message,
text_elements: Vec::new(),
}],
..Default::default()
})?;
println!("< turn/start response: {turn_response:?}");
Ok(())
})
client.stream_turn(&resume_response.thread.id, &turn_response.turn.id)?;
Ok(())
}
fn thread_resume_follow(
@@ -715,16 +698,6 @@ fn thread_resume_follow(
client.stream_notifications_forever()
}
fn watch(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
println!("< streaming inbound messages until process is terminated");
client.stream_notifications_forever()
}
fn trigger_cmd_approval(
endpoint: &Endpoint,
config_overrides: &[String],
@@ -795,34 +768,34 @@ fn send_message_v2_with_policies(
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize_with_experimental_api(experimental_api)?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: user_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = approval_policy;
turn_params.sandbox_policy = sandbox_policy;
let initialize = client.initialize_with_experimental_api(experimental_api)?;
println!("< initialize response: {initialize:?}");
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: user_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = approval_policy;
turn_params.sandbox_policy = sandbox_policy;
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
Ok(())
})
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
Ok(())
}
fn send_follow_up_v2(
@@ -832,130 +805,119 @@ fn send_follow_up_v2(
follow_up_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let first_turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: first_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
let first_turn_response = client.turn_start(first_turn_params)?;
println!("< turn/start response (initial): {first_turn_response:?}");
client.stream_turn(&thread_response.thread.id, &first_turn_response.turn.id)?;
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let follow_up_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: follow_up_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
let follow_up_response = client.turn_start(follow_up_params)?;
println!("< turn/start response (follow-up): {follow_up_response:?}");
client.stream_turn(&thread_response.thread.id, &follow_up_response.turn.id)?;
let first_turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: first_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
let first_turn_response = client.turn_start(first_turn_params)?;
println!("< turn/start response (initial): {first_turn_response:?}");
client.stream_turn(&thread_response.thread.id, &first_turn_response.turn.id)?;
Ok(())
})
let follow_up_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: follow_up_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
let follow_up_response = client.turn_start(follow_up_params)?;
println!("< turn/start response (follow-up): {follow_up_response:?}");
client.stream_turn(&thread_response.thread.id, &follow_up_response.turn.id)?;
Ok(())
}
fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let login_response = client.login_chat_gpt()?;
println!("< loginChatGpt response: {login_response:?}");
println!(
"Open the following URL in your browser to continue:\n{}",
login_response.auth_url
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let login_response = client.login_chat_gpt()?;
println!("< loginChatGpt response: {login_response:?}");
println!(
"Open the following URL in your browser to continue:\n{}",
login_response.auth_url
);
let completion = client.wait_for_login_completion(&login_response.login_id)?;
println!("< loginChatGptComplete notification: {completion:?}");
if completion.success {
println!("Login succeeded.");
Ok(())
} else {
bail!(
"login failed: {}",
completion
.error
.as_deref()
.unwrap_or("unknown error from loginChatGptComplete")
);
let completion = client.wait_for_login_completion(&login_response.login_id)?;
println!("< loginChatGptComplete notification: {completion:?}");
if completion.success {
println!("Login succeeded.");
Ok(())
} else {
bail!(
"login failed: {}",
completion
.error
.as_deref()
.unwrap_or("unknown error from loginChatGptComplete")
);
}
})
}
}
fn get_account_rate_limits(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let response = client.get_account_rate_limits()?;
println!("< account/rateLimits/read response: {response:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
Ok(())
})
let response = client.get_account_rate_limits()?;
println!("< account/rateLimits/read response: {response:?}");
Ok(())
}
fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let response = client.model_list(ModelListParams::default())?;
println!("< model/list response: {response:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
Ok(())
})
let response = client.model_list(ModelListParams::default())?;
println!("< model/list response: {response:?}");
Ok(())
}
fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let response = client.thread_list(ThreadListParams {
cursor: None,
limit: Some(limit),
sort_key: None,
model_providers: None,
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})?;
println!("< thread/list response: {response:?}");
Ok(())
})
}
fn with_client<T>(
endpoint: &Endpoint,
config_overrides: &[String],
f: impl FnOnce(&mut CodexClient) -> Result<T>,
) -> Result<T> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let result = f(&mut client);
client.print_trace_summary();
result
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let response = client.thread_list(ThreadListParams {
cursor: None,
limit: Some(limit),
sort_key: None,
model_providers: None,
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})?;
println!("< thread/list response: {response:?}");
Ok(())
}
fn ensure_dynamic_tools_unused(
@@ -1012,8 +974,6 @@ struct CodexClient {
command_approval_item_ids: Vec<String>,
command_execution_statuses: Vec<CommandExecutionStatus>,
last_turn_status: Option<TurnStatus>,
trace_id: String,
trace_root_span_id: String,
}
#[derive(Debug, Clone, Copy)]
@@ -1073,8 +1033,6 @@ impl CodexClient {
command_approval_item_ids: Vec::new(),
command_execution_statuses: Vec::new(),
last_turn_status: None,
trace_id: generate_trace_id(),
trace_root_span_id: generate_parent_span_id(),
})
}
@@ -1096,8 +1054,6 @@ impl CodexClient {
command_approval_item_ids: Vec::new(),
command_execution_statuses: Vec::new(),
last_turn_status: None,
trace_id: generate_trace_id(),
trace_root_span_id: generate_parent_span_id(),
})
}
@@ -1463,32 +1419,12 @@ impl CodexClient {
}
fn write_request(&mut self, request: &ClientRequest) -> Result<()> {
let request = self.jsonrpc_request_with_trace(request)?;
let request_json = serde_json::to_string(&request)?;
let request_pretty = serde_json::to_string_pretty(&request)?;
let request_json = serde_json::to_string(request)?;
let request_pretty = serde_json::to_string_pretty(request)?;
print_multiline_with_prefix("> ", &request_pretty);
self.write_payload(&request_json)
}
fn jsonrpc_request_with_trace(&self, request: &ClientRequest) -> Result<JSONRPCRequest> {
let request_value = serde_json::to_value(request)?;
let mut request: JSONRPCRequest = serde_json::from_value(request_value)
.context("client request was not a valid JSON-RPC request")?;
request.trace = Some(W3cTraceContext {
traceparent: Some(format!(
"00-{}-{}-01",
self.trace_id, self.trace_root_span_id
)),
tracestate: None,
});
Ok(request)
}
fn print_trace_summary(&self) {
println!("\n[Datadog trace]");
println!("go/trace/{}\n", self.trace_id);
}
fn wait_for_response<T>(&mut self, request_id: RequestId, method: &str) -> Result<T>
where
T: DeserializeOwned,
@@ -1754,15 +1690,6 @@ impl CodexClient {
}
}
fn generate_trace_id() -> String {
Uuid::new_v4().simple().to_string()
}
fn generate_parent_span_id() -> String {
let uuid = Uuid::new_v4().simple().to_string();
uuid[..16].to_string()
}
fn print_multiline_with_prefix(prefix: &str, payload: &str) {
for line in payload.lines() {
println!("{prefix}{line}");
@@ -1782,18 +1709,11 @@ impl Drop for CodexClient {
return;
}
let deadline = SystemTime::now() + APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT;
loop {
if let Ok(Some(status)) = child.try_wait() {
println!("[codex app-server exited: {status}]");
return;
}
thread::sleep(Duration::from_millis(100));
if SystemTime::now() >= deadline {
break;
}
thread::sleep(APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL);
if let Ok(Some(status)) = child.try_wait() {
println!("[codex app-server exited: {status}]");
return;
}
let _ = child.kill();

View File

@@ -21,7 +21,6 @@ async-trait = { workspace = true }
codex-arg0 = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-core = { workspace = true }
codex-otel = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
codex-backend-client = { workspace = true }

View File

@@ -62,8 +62,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
- Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected.
- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and youll also get a `thread/started` notification. If youre continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history.
The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`.
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running.
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object and triggers a `turn/started` notification.
- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. Youll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes).
- Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage.
@@ -120,16 +119,16 @@ Example with notification opt-out:
## API Overview
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread.
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for the new thread.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/status/changed` — notification emitted when a loaded threads status changes (`threadId` + new `status`).
- `thread/archive` — move a threads rollout file into the archived directory; returns `{}` on success and emits `thread/archived`.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`.
- `thread/name/set` — set or update a threads user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/name/set` — set or update a threads user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
@@ -273,11 +272,10 @@ When `nextCursor` is `null`, youve reached the final page.
### Example: Track thread status changes
`thread/status/changed` is emitted whenever a loaded thread's status changes after it has already been introduced to the client:
`thread/status/changed` is emitted whenever a loaded thread's status changes:
- Includes `threadId` and the new `status`.
- Status can be `notLoaded`, `idle`, `systemError`, or `active` (with `activeFlags`; `active` implies running).
- `thread/start`, `thread/fork`, and detached review threads do not emit a separate initial `thread/status/changed`; their `thread/started` notification already carries the current `thread.status`.
```json
{ "method": "thread/status/changed", "params": {
@@ -621,7 +619,7 @@ Because audio is intentionally separate from `ThreadItem`, clients can opt out o
### Turn events
The app-server streams JSON-RPC notifications while a turn is running. Each turn emits `turn/started` when it begins running and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
- `turn/started``{ turn }` with the turn id, empty `items`, and `status: "inProgress"`.
- `turn/completed``{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`.
@@ -714,8 +712,7 @@ Order of messages:
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`.
4. `serverRequest/resolved``{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt.
5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.
4. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.
### File change approvals
@@ -724,15 +721,10 @@ Order of messages:
1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user.
2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, and an optional `reason`.
3. Client response — `{ "decision": "accept" }` or `{ "decision": "decline" }`.
4. `serverRequest/resolved``{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt.
5. `item/completed` — returns the same `fileChange` item with `status` updated to `completed`, `failed`, or `declined` after the patch attempt. Rely on this to show success/failure and finalize the diff state in your UI.
4. `item/completed` — returns the same `fileChange` item with `status` updated to `completed`, `failed`, or `declined` after the patch attempt. Rely on this to show success/failure and finalize the diff state in your UI.
UI guidance for IDEs: surface an approval dialog as soon as the request arrives. The turn will proceed after the server receives a response to the approval request. The terminal `item/completed` notification will be sent with the appropriate status.
### request_user_input
When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup.
### Dynamic tool calls (experimental)
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
@@ -953,7 +945,7 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
### Authentication modes
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`.
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`) and can be inferred from `account/read`.
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"`; Codex persists tokens to disk and refreshes them automatically.
@@ -965,7 +957,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
- `account/login/cancel` — cancel a pending ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`).
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
@@ -1009,7 +1001,7 @@ Field notes:
3. Notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "apikey", "planType": null } }
{ "method": "account/updated", "params": { "authMode": "apikey" } }
```
### 3) Log in with ChatGPT (browser flow)
@@ -1023,7 +1015,7 @@ Field notes:
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
{ "method": "account/updated", "params": { "authMode": "chatgpt" } }
```
### 4) Cancel a ChatGPT login
@@ -1038,7 +1030,7 @@ Field notes:
```json
{ "method": "account/logout", "id": 5 }
{ "id": 5, "result": {} }
{ "method": "account/updated", "params": { "authMode": null, "planType": null } }
{ "method": "account/updated", "params": { "authMode": null } }
```
### 6) Rate limits (ChatGPT)

View File

@@ -1,101 +0,0 @@
use crate::message_processor::ConnectionSessionState;
use crate::outgoing_message::ConnectionId;
use crate::transport::AppServerTransport;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCRequest;
use codex_otel::set_parent_from_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_otel::traceparent_context_from_env;
use codex_protocol::protocol::W3cTraceContext;
use tracing::Span;
use tracing::field;
use tracing::info_span;
pub(crate) fn request_span(
request: &JSONRPCRequest,
transport: AppServerTransport,
connection_id: ConnectionId,
session: &ConnectionSessionState,
) -> Span {
let span = info_span!(
"app_server.request",
otel.kind = "server",
otel.name = request.method.as_str(),
rpc.system = "jsonrpc",
rpc.method = request.method.as_str(),
rpc.transport = transport_name(transport),
rpc.request_id = ?request.id,
app_server.connection_id = ?connection_id,
app_server.api_version = "v2",
app_server.client_name = field::Empty,
app_server.client_version = field::Empty,
);
let initialize_client_info = initialize_client_info(request);
if let Some(client_name) = client_name(initialize_client_info.as_ref(), session) {
span.record("app_server.client_name", client_name);
}
if let Some(client_version) = client_version(initialize_client_info.as_ref(), session) {
span.record("app_server.client_version", client_version);
}
if let Some(traceparent) = request
.trace
.as_ref()
.and_then(|trace| trace.traceparent.as_deref())
{
let trace = W3cTraceContext {
traceparent: Some(traceparent.to_string()),
tracestate: request
.trace
.as_ref()
.and_then(|value| value.tracestate.clone()),
};
if !set_parent_from_w3c_trace_context(&span, &trace) {
tracing::warn!(
rpc_method = request.method.as_str(),
rpc_request_id = ?request.id,
"ignoring invalid inbound request trace carrier"
);
}
} else if let Some(context) = traceparent_context_from_env() {
set_parent_from_context(&span, context);
}
span
}
fn transport_name(transport: AppServerTransport) -> &'static str {
match transport {
AppServerTransport::Stdio => "stdio",
AppServerTransport::WebSocket { .. } => "websocket",
}
}
fn client_name<'a>(
initialize_client_info: Option<&'a InitializeParams>,
session: &'a ConnectionSessionState,
) -> Option<&'a str> {
if let Some(params) = initialize_client_info {
return Some(params.client_info.name.as_str());
}
session.app_server_client_name.as_deref()
}
fn client_version<'a>(
initialize_client_info: Option<&'a InitializeParams>,
session: &'a ConnectionSessionState,
) -> Option<&'a str> {
if let Some(params) = initialize_client_info {
return Some(params.client_info.version.as_str());
}
session.client_version.as_deref()
}
fn initialize_client_info(request: &JSONRPCRequest) -> Option<InitializeParams> {
if request.method != "initialize" {
return None;
}
let params = request.params.clone()?;
serde_json::from_value(params).ok()
}

View File

@@ -6,8 +6,6 @@ use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::server_request_error::is_turn_transition_server_request_error;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadState;
use crate::thread_state::TurnSummary;
use crate::thread_status::ThreadWatchActiveGuard;
@@ -58,7 +56,6 @@ use codex_app_server_protocol::RawResponseItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ReasoningTextDeltaNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::TerminalInteractionNotification;
@@ -83,7 +80,6 @@ use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnPlanStep;
use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_app_server_protocol::convert_patch_changes;
@@ -136,38 +132,6 @@ struct CommandExecutionCompletionItem {
command_actions: Vec<V2ParsedCommand>,
}
async fn resolve_server_request_on_thread_listener(
thread_state: &Arc<Mutex<ThreadState>>,
request_id: RequestId,
) {
let (completion_tx, completion_rx) = oneshot::channel();
let listener_command_tx = {
let state = thread_state.lock().await;
state.listener_command_tx()
};
let Some(listener_command_tx) = listener_command_tx else {
error!("failed to remove pending client request: thread listener is not running");
return;
};
if listener_command_tx
.send(ThreadListenerCommand::ResolveServerRequest {
request_id,
completion_tx,
})
.is_err()
{
error!(
"failed to remove pending client request: thread listener command channel is closed"
);
return;
}
if let Err(err) = completion_rx.await {
error!("failed to remove pending client request: {err}");
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn apply_bespoke_event_handling(
event: Event,
@@ -186,34 +150,12 @@ pub(crate) async fn apply_bespoke_event_handling(
msg,
} = event;
match msg {
EventMsg::TurnStarted(payload) => {
// While not technically necessary as it was already done on TurnComplete, be extra cautios and abort any pending server requests.
outgoing.abort_pending_server_requests().await;
EventMsg::TurnStarted(_) => {
thread_watch_manager
.note_turn_started(&conversation_id.to_string())
.await;
if let ApiVersion::V2 = api_version {
let turn = {
let state = thread_state.lock().await;
state.active_turn_snapshot().unwrap_or_else(|| Turn {
id: payload.turn_id.clone(),
items: Vec::new(),
error: None,
status: TurnStatus::InProgress,
})
};
let notification = TurnStartedNotification {
thread_id: conversation_id.to_string(),
turn,
};
outgoing
.send_server_notification(ServerNotification::TurnStarted(notification))
.await;
}
}
EventMsg::TurnComplete(_ev) => {
// All per-thread requests are bound to a turn, so abort them.
outgoing.abort_pending_server_requests().await;
let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some();
thread_watch_manager
.note_turn_completed(&conversation_id.to_string(), turn_failed)
@@ -251,6 +193,7 @@ pub(crate) async fn apply_bespoke_event_handling(
EventMsg::RealtimeConversationRealtime(event) => {
if let ApiVersion::V2 = api_version {
match event.payload {
RealtimeEvent::SessionCreated { .. } => {}
RealtimeEvent::SessionUpdated { .. } => {}
RealtimeEvent::AudioOut(audio) => {
let notification = ThreadRealtimeOutputAudioDeltaNotification {
@@ -274,24 +217,6 @@ pub(crate) async fn apply_bespoke_event_handling(
))
.await;
}
RealtimeEvent::ConversationItemDone { .. } => {}
RealtimeEvent::HandoffRequested(handoff) => {
let notification = ThreadRealtimeItemAddedNotification {
thread_id: conversation_id.to_string(),
item: serde_json::json!({
"type": "handoff_request",
"handoff_id": handoff.handoff_id,
"item_id": handoff.item_id,
"input_transcript": handoff.input_transcript,
"messages": handoff.messages,
}),
};
outgoing
.send_server_notification(ServerNotification::ThreadRealtimeItemAdded(
notification,
))
.await;
}
RealtimeEvent::Error(message) => {
let notification = ThreadRealtimeErrorNotification {
thread_id: conversation_id.to_string(),
@@ -338,7 +263,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
grant_root,
};
let (_pending_request_id, rx) = outgoing
let rx = outgoing
.send_request(ServerRequestPayload::ApplyPatchApproval(params))
.await;
tokio::spawn(async move {
@@ -382,7 +307,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
grant_root,
};
let (pending_request_id, rx) = outgoing
let rx = outgoing
.send_request(ServerRequestPayload::FileChangeRequestApproval(params))
.await;
tokio::spawn(async move {
@@ -391,7 +316,6 @@ pub(crate) async fn apply_bespoke_event_handling(
conversation_id,
item_id,
patch_changes,
pending_request_id,
rx,
conversation,
outgoing,
@@ -438,7 +362,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
parsed_cmd,
};
let (_pending_request_id, rx) = outgoing
let rx = outgoing
.send_request(ServerRequestPayload::ExecCommandApproval(params))
.await;
tokio::spawn(async move {
@@ -511,7 +435,7 @@ pub(crate) async fn apply_bespoke_event_handling(
proposed_network_policy_amendments: proposed_network_policy_amendments_v2,
available_decisions: Some(available_decisions),
};
let (pending_request_id, rx) = outgoing
let rx = outgoing
.send_request(ServerRequestPayload::CommandExecutionRequestApproval(
params,
))
@@ -523,7 +447,6 @@ pub(crate) async fn apply_bespoke_event_handling(
approval_id,
call_id,
completion_item,
pending_request_id,
rx,
conversation,
outgoing,
@@ -566,16 +489,14 @@ pub(crate) async fn apply_bespoke_event_handling(
item_id: request.call_id,
questions,
};
let (pending_request_id, rx) = outgoing
let rx = outgoing
.send_request(ServerRequestPayload::ToolRequestUserInput(params))
.await;
tokio::spawn(async move {
on_request_user_input_response(
event_turn_id,
pending_request_id,
rx,
conversation,
thread_state,
user_input_guard,
)
.await;
@@ -629,7 +550,7 @@ pub(crate) async fn apply_bespoke_event_handling(
tool: tool.clone(),
arguments: arguments.clone(),
};
let (_pending_request_id, rx) = outgoing
let rx = outgoing
.send_request(ServerRequestPayload::DynamicToolCall(params))
.await;
tokio::spawn(async move {
@@ -1215,7 +1136,6 @@ pub(crate) async fn apply_bespoke_event_handling(
// Until we migrate the core to be aware of a first class FileChangeItem
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
let item_id = patch_begin_event.call_id.clone();
let changes = convert_patch_changes(&patch_begin_event.changes);
let first_start = {
let mut state = thread_state.lock().await;
@@ -1227,7 +1147,7 @@ pub(crate) async fn apply_bespoke_event_handling(
if first_start {
let item = ThreadItem::FileChange {
id: item_id.clone(),
changes,
changes: convert_patch_changes(&patch_begin_event.changes),
status: PatchApplyStatus::InProgress,
};
let notification = ItemStartedNotification {
@@ -1409,8 +1329,6 @@ pub(crate) async fn apply_bespoke_event_handling(
}
// If this is a TurnAborted, reply to any pending interrupt requests.
EventMsg::TurnAborted(turn_aborted_event) => {
// All per-thread requests are bound to a turn, so abort them.
outgoing.abort_pending_server_requests().await;
let pending = {
let mut state = thread_state.lock().await;
std::mem::take(&mut state.pending_interrupts)
@@ -1807,7 +1725,6 @@ async fn on_patch_approval_response(
let response = receiver.await;
let value = match response {
Ok(Ok(value)) => value,
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
if let Err(submit_err) = codex
@@ -1864,7 +1781,6 @@ async fn on_exec_approval_response(
let response = receiver.await;
let value = match response {
Ok(Ok(value)) => value,
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
return;
@@ -1900,18 +1816,14 @@ async fn on_exec_approval_response(
async fn on_request_user_input_response(
event_turn_id: String,
pending_request_id: RequestId,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
thread_state: Arc<Mutex<ThreadState>>,
user_input_guard: ThreadWatchActiveGuard,
) {
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
drop(user_input_guard);
let value = match response {
Ok(Ok(value)) => value,
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
let empty = CoreRequestUserInputResponse {
@@ -2022,7 +1934,6 @@ async fn on_file_change_request_approval_response(
conversation_id: ThreadId,
item_id: String,
changes: Vec<FileUpdateChange>,
pending_request_id: RequestId,
receiver: oneshot::Receiver<ClientRequestResult>,
codex: Arc<CodexThread>,
outgoing: ThreadScopedOutgoingMessageSender,
@@ -2030,7 +1941,6 @@ async fn on_file_change_request_approval_response(
permission_guard: ThreadWatchActiveGuard,
) {
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
drop(permission_guard);
let (decision, completion_status) = match response {
Ok(Ok(value)) => {
@@ -2048,7 +1958,6 @@ async fn on_file_change_request_approval_response(
// Only short-circuit on declines/cancels/failures.
(decision, completion_status)
}
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
@@ -2090,7 +1999,6 @@ async fn on_command_execution_request_approval_response(
approval_id: Option<String>,
item_id: String,
completion_item: Option<CommandExecutionCompletionItem>,
pending_request_id: RequestId,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
outgoing: ThreadScopedOutgoingMessageSender,
@@ -2098,7 +2006,6 @@ async fn on_command_execution_request_approval_response(
permission_guard: ThreadWatchActiveGuard,
) {
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
drop(permission_guard);
let (decision, completion_status) = match response {
Ok(Ok(value)) => {
@@ -2150,7 +2057,6 @@ async fn on_command_execution_request_approval_response(
};
(decision, completion_status)
}
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
(ReviewDecision::Denied, Some(CommandExecutionStatus::Failed))

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ use tokio::sync::oneshot;
use tracing::error;
use crate::outgoing_message::ClientRequestResult;
use crate::server_request_error::is_turn_transition_server_request_error;
pub(crate) async fn on_call_response(
call_id: String,
@@ -19,7 +18,6 @@ pub(crate) async fn on_call_response(
let response = receiver.await;
let (response, _error) = match response {
Ok(Ok(value)) => decode_response(value),
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
fallback_response("dynamic tool request failed")

View File

@@ -52,7 +52,6 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
mod app_server_tracing;
mod bespoke_event_handling;
mod codex_message_processor;
mod config_api;
@@ -64,7 +63,6 @@ mod fuzzy_file_search;
mod message_processor;
mod models;
mod outgoing_message;
mod server_request_error;
mod thread_state;
mod thread_status;
mod transport;
@@ -448,7 +446,7 @@ pub async fn run_main_with_transport(
let otel = codex_core::otel_init::build_provider(
&config,
env!("CARGO_PKG_VERSION"),
Some("codex-app-server"),
Some("codex_app_server"),
default_analytics_enabled,
)
.map_err(|e| {
@@ -558,6 +556,7 @@ pub async fn run_main_with_transport(
outgoing: outgoing_message_sender,
arg0_paths,
config: Arc::new(config),
single_client_mode,
cli_overrides,
loader_overrides,
cloud_requirements: cloud_requirements.clone(),
@@ -675,7 +674,6 @@ pub async fn run_main_with_transport(
.process_request(
connection_id,
request,
transport,
&mut connection_state.session,
&connection_state.outbound_initialized,
)

View File

@@ -12,7 +12,6 @@ use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::transport::AppServerTransport;
use async_trait::async_trait;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
@@ -60,7 +59,6 @@ use tokio::sync::watch;
use tokio::time::Duration;
use tokio::time::timeout;
use toml::Value as TomlValue;
use tracing::Instrument;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
@@ -143,13 +141,13 @@ pub(crate) struct ConnectionSessionState {
pub(crate) experimental_api_enabled: bool,
pub(crate) opted_out_notification_methods: HashSet<String>,
pub(crate) app_server_client_name: Option<String>,
pub(crate) client_version: Option<String>,
}
pub(crate) struct MessageProcessorArgs {
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) arg0_paths: Arg0DispatchPaths,
pub(crate) config: Arc<Config>,
pub(crate) single_client_mode: bool,
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) loader_overrides: LoaderOverrides,
pub(crate) cloud_requirements: CloudRequirementsLoader,
@@ -165,6 +163,7 @@ impl MessageProcessor {
outgoing,
arg0_paths,
config,
single_client_mode,
cli_overrides,
loader_overrides,
cloud_requirements,
@@ -200,6 +199,7 @@ impl MessageProcessor {
config: Arc::clone(&config),
cli_overrides: cli_overrides.clone(),
cloud_requirements: cloud_requirements.clone(),
single_client_mode,
feedback,
});
let config_api = ConfigApi::new(
@@ -224,50 +224,46 @@ impl MessageProcessor {
&mut self,
connection_id: ConnectionId,
request: JSONRPCRequest,
transport: AppServerTransport,
session: &mut ConnectionSessionState,
outbound_initialized: &AtomicBool,
) {
let request_span =
crate::app_server_tracing::request_span(&request, transport, connection_id, session);
async {
let request_method = request.method.as_str();
tracing::trace!(
?connection_id,
request_id = ?request.id,
"app-server request: {request_method}"
);
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id.clone(),
};
let request_json = match serde_json::to_value(&request) {
Ok(request_json) => request_json,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let request_method = request.method.as_str();
tracing::trace!(
?connection_id,
request_id = ?request.id,
"app-server request: {request_method}"
);
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id.clone(),
};
let request_json = match serde_json::to_value(&request) {
Ok(request_json) => request_json,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let codex_request = match serde_json::from_value::<ClientRequest>(request_json) {
Ok(codex_request) => codex_request,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let codex_request = match serde_json::from_value::<ClientRequest>(request_json) {
Ok(codex_request) => codex_request,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match codex_request {
match codex_request {
// Handle Initialize internally so CodexMessageProcessor does not have to concern
// itself with the `initialized` bool.
ClientRequest::Initialize { request_id, params } => {
@@ -308,8 +304,6 @@ impl MessageProcessor {
title: _title,
version,
} = params.client_info;
session.app_server_client_name = Some(name.clone());
session.client_version = Some(version.clone());
if let Err(error) = set_default_originator(name.clone()) {
match error {
SetOriginatorError::InvalidHeaderValue => {
@@ -336,6 +330,7 @@ impl MessageProcessor {
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
*suffix = Some(user_agent_suffix);
}
session.app_server_client_name = Some(name.clone());
let user_agent = get_codex_user_agent();
let response = InitializeResponse { user_agent };
@@ -343,9 +338,6 @@ impl MessageProcessor {
session.initialized = true;
outbound_initialized.store(true, Ordering::Release);
self.codex_message_processor
.connection_initialized(connection_id)
.await;
return;
}
}
@@ -360,97 +352,91 @@ impl MessageProcessor {
return;
}
}
}
if let Some(reason) = codex_request.experimental_reason()
&& !session.experimental_api_enabled
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: experimental_required_message(reason),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
}
match codex_request {
ClientRequest::ConfigRead { request_id, params } => {
self.handle_config_read(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ExternalAgentConfigDetect { request_id, params } => {
self.handle_external_agent_config_detect(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ExternalAgentConfigImport { request_id, params } => {
self.handle_external_agent_config_import(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigValueWrite { request_id, params } => {
self.handle_config_value_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigBatchWrite { request_id, params } => {
self.handle_config_batch_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigRequirementsRead {
request_id,
params: _,
} => {
self.handle_config_requirements_read(ConnectionRequestId {
if let Some(reason) = codex_request.experimental_reason()
&& !session.experimental_api_enabled
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: experimental_required_message(reason),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
match codex_request {
ClientRequest::ConfigRead { request_id, params } => {
self.handle_config_read(
ConnectionRequestId {
connection_id,
request_id,
})
},
params,
)
.await;
}
ClientRequest::ExternalAgentConfigDetect { request_id, params } => {
self.handle_external_agent_config_detect(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ExternalAgentConfigImport { request_id, params } => {
self.handle_external_agent_config_import(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigValueWrite { request_id, params } => {
self.handle_config_value_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigBatchWrite { request_id, params } => {
self.handle_config_batch_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigRequirementsRead {
request_id,
params: _,
} => {
self.handle_config_requirements_read(ConnectionRequestId {
connection_id,
request_id,
})
.await;
}
other => {
// Box the delegated future so this wrapper's async state machine does not
// inline the full `CodexMessageProcessor::process_request` future, which
// can otherwise push worker-thread stack usage over the edge.
self.codex_message_processor
.process_request(connection_id, other, session.app_server_client_name.clone())
.boxed()
.await;
}
other => {
// Box the delegated future so this wrapper's async state machine does not
// inline the full `CodexMessageProcessor::process_request` future, which
// can otherwise push worker-thread stack usage over the edge.
self.codex_message_processor
.process_request(
connection_id,
other,
session.app_server_client_name.clone(),
)
.boxed()
.await;
}
}
}
.instrument(request_span)
.await;
}
pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) {

View File

@@ -17,7 +17,6 @@ use tokio::sync::oneshot;
use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON;
#[cfg(test)]
use codex_protocol::account::PlanType;
@@ -63,7 +62,6 @@ pub(crate) struct ThreadScopedOutgoingMessageSender {
struct PendingCallbackEntry {
callback: oneshot::Sender<ClientRequestResult>,
thread_id: Option<ThreadId>,
request: ServerRequest,
}
impl ThreadScopedOutgoingMessageSender {
@@ -82,12 +80,12 @@ impl ThreadScopedOutgoingMessageSender {
pub(crate) async fn send_request(
&self,
payload: ServerRequestPayload,
) -> (RequestId, oneshot::Receiver<ClientRequestResult>) {
) -> oneshot::Receiver<ClientRequestResult> {
self.outgoing
.send_request_to_connections(
Some(self.connection_ids.as_slice()),
.send_request_to_thread_connections(
self.thread_id,
self.connection_ids.as_slice(),
payload,
Some(self.thread_id),
)
.await
}
@@ -101,20 +99,6 @@ impl ThreadScopedOutgoingMessageSender {
.await;
}
pub(crate) async fn abort_pending_server_requests(&self) {
self.outgoing
.cancel_requests_for_thread(
self.thread_id,
Some(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "client request resolved because the turn state was changed"
.to_string(),
data: Some(serde_json::json!({ "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON })),
}),
)
.await
}
pub(crate) async fn send_response<T: Serialize>(
&self,
request_id: ConnectionRequestId,
@@ -145,23 +129,38 @@ impl OutgoingMessageSender {
&self,
request: ServerRequestPayload,
) -> (RequestId, oneshot::Receiver<ClientRequestResult>) {
self.send_request_to_connections(None, request, None).await
self.send_request_with_id_to_connections(&[], request, None)
.await
}
async fn send_request_to_thread_connections(
&self,
thread_id: ThreadId,
connection_ids: &[ConnectionId],
request: ServerRequestPayload,
) -> oneshot::Receiver<ClientRequestResult> {
if connection_ids.is_empty() {
let (_tx, rx) = oneshot::channel();
return rx;
}
let (_request_id, receiver) = self
.send_request_with_id_to_connections(connection_ids, request, Some(thread_id))
.await;
receiver
}
fn next_request_id(&self) -> RequestId {
RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed))
}
async fn send_request_to_connections(
async fn send_request_with_id_to_connections(
&self,
connection_ids: Option<&[ConnectionId]>,
connection_ids: &[ConnectionId],
request: ServerRequestPayload,
thread_id: Option<ThreadId>,
) -> (RequestId, oneshot::Receiver<ClientRequestResult>) {
let id = self.next_request_id();
let outgoing_message_id = id.clone();
let request = request.request_with_id(outgoing_message_id.clone());
let (tx_approve, rx_approve) = oneshot::channel();
{
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
@@ -170,39 +169,36 @@ impl OutgoingMessageSender {
PendingCallbackEntry {
callback: tx_approve,
thread_id,
request: request.clone(),
},
);
}
let outgoing_message = OutgoingMessage::Request(request);
let send_result = match connection_ids {
None => {
self.sender
.send(OutgoingEnvelope::Broadcast {
message: outgoing_message,
let outgoing_message =
OutgoingMessage::Request(request.request_with_id(outgoing_message_id.clone()));
let send_result = if connection_ids.is_empty() {
self.sender
.send(OutgoingEnvelope::Broadcast {
message: outgoing_message,
})
.await
} else {
let mut send_error = None;
for connection_id in connection_ids {
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id: *connection_id,
message: outgoing_message.clone(),
})
.await
{
send_error = Some(err);
break;
}
}
Some(connection_ids) => {
let mut send_error = None;
for connection_id in connection_ids {
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id: *connection_id,
message: outgoing_message.clone(),
})
.await
{
send_error = Some(err);
break;
}
}
match send_error {
Some(err) => Err(err),
None => Ok(()),
}
match send_error {
Some(err) => Err(err),
None => Ok(()),
}
};
@@ -214,28 +210,11 @@ impl OutgoingMessageSender {
(outgoing_message_id, rx_approve)
}
pub(crate) async fn replay_requests_to_connection_for_thread(
&self,
connection_id: ConnectionId,
thread_id: ThreadId,
) {
let requests = self.pending_requests_for_thread(thread_id).await;
for request in requests {
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Request(request),
})
.await
{
warn!("failed to resend request to client: {err:?}");
}
}
}
pub(crate) async fn notify_client_response(&self, id: RequestId, result: Result) {
let entry = self.take_request_callback(&id).await;
let entry = {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove_entry(&id)
};
match entry {
Some((id, entry)) => {
@@ -250,7 +229,10 @@ impl OutgoingMessageSender {
}
pub(crate) async fn notify_client_error(&self, id: RequestId, error: JSONRPCErrorError) {
let entry = self.take_request_callback(&id).await;
let entry = {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove_entry(&id)
};
match entry {
Some((id, entry)) => {
@@ -266,62 +248,23 @@ impl OutgoingMessageSender {
}
pub(crate) async fn cancel_request(&self, id: &RequestId) -> bool {
self.take_request_callback(id).await.is_some()
let entry = {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove_entry(id)
};
entry.is_some()
}
async fn take_request_callback(
&self,
id: &RequestId,
) -> Option<(RequestId, PendingCallbackEntry)> {
pub(crate) async fn cancel_requests_for_thread(&self, thread_id: ThreadId) {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove_entry(id)
}
pub(crate) async fn pending_requests_for_thread(
&self,
thread_id: ThreadId,
) -> Vec<ServerRequest> {
let request_id_to_callback = self.request_id_to_callback.lock().await;
let mut requests = request_id_to_callback
let request_ids = request_id_to_callback
.iter()
.filter_map(|(_, entry)| {
(entry.thread_id == Some(thread_id)).then_some(entry.request.clone())
.filter_map(|(request_id, entry)| {
(entry.thread_id == Some(thread_id)).then_some(request_id.clone())
})
.collect::<Vec<_>>();
requests.sort_by(|left, right| left.id().cmp(right.id()));
requests
}
pub(crate) async fn cancel_requests_for_thread(
&self,
thread_id: ThreadId,
error: Option<JSONRPCErrorError>,
) {
let entries = {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
let request_ids = request_id_to_callback
.iter()
.filter_map(|(request_id, entry)| {
(entry.thread_id == Some(thread_id)).then_some(request_id.clone())
})
.collect::<Vec<_>>();
let mut entries = Vec::with_capacity(request_ids.len());
for request_id in request_ids {
if let Some(entry) = request_id_to_callback.remove(&request_id) {
entries.push(entry);
}
}
entries
};
if let Some(error) = error {
for entry in entries {
if let Err(err) = entry.callback.send(Err(error.clone())) {
let request_id = entry.request.id();
warn!("could not notify callback for {request_id:?} due to: {err:?}",);
}
}
for request_id in request_ids {
request_id_to_callback.remove(&request_id);
}
}
@@ -498,18 +441,14 @@ mod tests {
use codex_app_server_protocol::ApplyPatchApprovalParams;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::DynamicToolCallParams;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::LoginChatGptCompleteNotification;
use codex_app_server_protocol::ModelRerouteReason;
use codex_app_server_protocol::ModelReroutedNotification;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use tokio::time::timeout;
use uuid::Uuid;
@@ -612,7 +551,6 @@ mod tests {
fn verify_account_updated_notification_serialization() {
let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification {
auth_mode: Some(AuthMode::ApiKey),
plan_type: None,
});
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
@@ -620,8 +558,7 @@ mod tests {
json!({
"method": "account/updated",
"params": {
"authMode": "apikey",
"planType": null
"authMode": "apikey"
},
}),
serde_json::to_value(jsonrpc_notification)
@@ -786,121 +723,4 @@ mod tests {
.expect("waiter should receive a callback");
assert_eq!(result, Err(error));
}
#[tokio::test]
async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() {
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(8);
let outgoing = Arc::new(OutgoingMessageSender::new(tx));
let thread_id = ThreadId::new();
let thread_outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing.clone(),
vec![ConnectionId(1)],
thread_id,
);
let (dynamic_tool_request_id, _dynamic_tool_waiter) = thread_outgoing
.send_request(ServerRequestPayload::DynamicToolCall(
DynamicToolCallParams {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
call_id: "call-0".to_string(),
tool: "tool".to_string(),
arguments: json!({}),
},
))
.await;
let (first_request_id, _first_waiter) = thread_outgoing
.send_request(ServerRequestPayload::ToolRequestUserInput(
ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
},
))
.await;
let (second_request_id, _second_waiter) = thread_outgoing
.send_request(ServerRequestPayload::FileChangeRequestApproval(
FileChangeRequestApprovalParams {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-2".to_string(),
reason: None,
grant_root: None,
},
))
.await;
let pending_requests = outgoing.pending_requests_for_thread(thread_id).await;
assert_eq!(
pending_requests
.iter()
.map(ServerRequest::id)
.collect::<Vec<_>>(),
vec![
&dynamic_tool_request_id,
&first_request_id,
&second_request_id
]
);
}
#[tokio::test]
async fn cancel_requests_for_thread_cancels_all_thread_requests() {
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(8);
let outgoing = Arc::new(OutgoingMessageSender::new(tx));
let thread_id = ThreadId::new();
let thread_outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing.clone(),
vec![ConnectionId(1)],
thread_id,
);
let (_dynamic_tool_request_id, dynamic_tool_waiter) = thread_outgoing
.send_request(ServerRequestPayload::DynamicToolCall(
DynamicToolCallParams {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
call_id: "call-0".to_string(),
tool: "tool".to_string(),
arguments: json!({}),
},
))
.await;
let (_request_id, user_input_waiter) = thread_outgoing
.send_request(ServerRequestPayload::ToolRequestUserInput(
ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
},
))
.await;
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "tracked request cancelled".to_string(),
data: None,
};
outgoing
.cancel_requests_for_thread(thread_id, Some(error.clone()))
.await;
let dynamic_tool_result = timeout(Duration::from_secs(1), dynamic_tool_waiter)
.await
.expect("dynamic tool waiter should resolve")
.expect("dynamic tool waiter should receive a callback");
let user_input_result = timeout(Duration::from_secs(1), user_input_waiter)
.await
.expect("user input waiter should resolve")
.expect("user input waiter should receive a callback");
assert_eq!(dynamic_tool_result, Err(error.clone()));
assert_eq!(user_input_result, Err(error));
assert!(
outgoing
.pending_requests_for_thread(thread_id)
.await
.is_empty()
);
}
}

View File

@@ -1,42 +0,0 @@
use codex_app_server_protocol::JSONRPCErrorError;
pub(crate) const TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON: &str = "turnTransition";
pub(crate) fn is_turn_transition_server_request_error(error: &JSONRPCErrorError) -> bool {
error
.data
.as_ref()
.and_then(|data| data.get("reason"))
.and_then(serde_json::Value::as_str)
== Some(TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON)
}
#[cfg(test)]
mod tests {
use super::is_turn_transition_server_request_error;
use codex_app_server_protocol::JSONRPCErrorError;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn turn_transition_error_is_detected() {
let error = JSONRPCErrorError {
code: -1,
message: "client request resolved because the turn state was changed".to_string(),
data: Some(json!({ "reason": "turnTransition" })),
};
assert_eq!(is_turn_transition_server_request_error(&error), true);
}
#[test]
fn unrelated_error_is_not_detected() {
let error = JSONRPCErrorError {
code: -1,
message: "boom".to_string(),
data: Some(json!({ "reason": "other" })),
};
assert_eq!(is_turn_transition_server_request_error(&error), false);
}
}

View File

@@ -1,6 +1,5 @@
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
@@ -29,16 +28,8 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) config_snapshot: ThreadConfigSnapshot,
}
// ThreadListenerCommand is used to perform operations in the context of the thread listener, for serialization purposes.
pub(crate) enum ThreadListenerCommand {
// SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates.
SendThreadResumeResponse(Box<PendingThreadResumeRequest>),
// ResolveServerRequest is used to notify the client that the request has been resolved.
// It is executed in the thread listener's context to ensure that the resolved notification is ordered with regard to the request itself.
ResolveServerRequest {
request_id: RequestId,
completion_tx: oneshot::Sender<()>,
},
SendThreadResumeResponse(PendingThreadResumeRequest),
}
/// Per-conversation accumulation of the latest states e.g. error message while a turn runs.
@@ -60,6 +51,7 @@ pub(crate) struct ThreadState {
listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,
current_turn_history: ThreadHistoryBuilder,
listener_thread: Option<Weak<CodexThread>>,
subscribed_connections: HashSet<ConnectionId>,
}
impl ThreadState {
@@ -94,6 +86,18 @@ impl ThreadState {
self.listener_thread = None;
}
pub(crate) fn add_connection(&mut self, connection_id: ConnectionId) {
self.subscribed_connections.insert(connection_id);
}
pub(crate) fn remove_connection(&mut self, connection_id: ConnectionId) {
self.subscribed_connections.remove(&connection_id);
}
pub(crate) fn subscribed_connection_ids(&self) -> Vec<ConnectionId> {
self.subscribed_connections.iter().copied().collect()
}
pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) {
self.experimental_raw_events = enabled;
}
@@ -122,112 +126,55 @@ struct SubscriptionState {
connection_id: ConnectionId,
}
struct ThreadEntry {
state: Arc<Mutex<ThreadState>>,
connection_ids: HashSet<ConnectionId>,
}
impl Default for ThreadEntry {
fn default() -> Self {
Self {
state: Arc::new(Mutex::new(ThreadState::default())),
connection_ids: HashSet::new(),
}
}
}
#[derive(Default)]
struct ThreadStateManagerInner {
live_connections: HashSet<ConnectionId>,
threads: HashMap<ThreadId, ThreadEntry>,
pub(crate) struct ThreadStateManager {
thread_states: HashMap<ThreadId, Arc<Mutex<ThreadState>>>,
subscription_state_by_id: HashMap<Uuid, SubscriptionState>,
thread_ids_by_connection: HashMap<ConnectionId, HashSet<ThreadId>>,
}
#[derive(Clone, Default)]
pub(crate) struct ThreadStateManager {
state: Arc<Mutex<ThreadStateManagerInner>>,
}
impl ThreadStateManager {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
self.state
.lock()
.await
.live_connections
.insert(connection_id);
pub(crate) fn thread_state(&mut self, thread_id: ThreadId) -> Arc<Mutex<ThreadState>> {
self.thread_states
.entry(thread_id)
.or_insert_with(|| Arc::new(Mutex::new(ThreadState::default())))
.clone()
}
pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec<ConnectionId> {
let state = self.state.lock().await;
state
.threads
.get(&thread_id)
.map(|thread_entry| thread_entry.connection_ids.iter().copied().collect())
.unwrap_or_default()
}
pub(crate) async fn thread_state(&self, thread_id: ThreadId) -> Arc<Mutex<ThreadState>> {
let mut state = self.state.lock().await;
state.threads.entry(thread_id).or_default().state.clone()
}
pub(crate) async fn remove_listener(&self, subscription_id: Uuid) -> Option<ThreadId> {
let (subscription_state, connection_still_subscribed_to_thread, thread_state) = {
let mut state = self.state.lock().await;
let subscription_state = state.subscription_state_by_id.remove(&subscription_id)?;
let thread_id = subscription_state.thread_id;
let connection_still_subscribed_to_thread = state
.subscription_state_by_id
.values()
.any(|subscription_state_entry| {
subscription_state_entry.thread_id == thread_id
&& subscription_state_entry.connection_id
== subscription_state.connection_id
});
if !connection_still_subscribed_to_thread {
let mut remove_connection_entry = false;
if let Some(thread_ids) = state
.thread_ids_by_connection
.get_mut(&subscription_state.connection_id)
{
thread_ids.remove(&thread_id);
remove_connection_entry = thread_ids.is_empty();
}
if remove_connection_entry {
state
.thread_ids_by_connection
.remove(&subscription_state.connection_id);
}
if let Some(thread_entry) = state.threads.get_mut(&thread_id) {
thread_entry
.connection_ids
.remove(&subscription_state.connection_id);
}
}
let thread_state = state.threads.get(&thread_id).map(|thread_entry| {
(
thread_entry.connection_ids.is_empty(),
thread_entry.state.clone(),
)
});
(
subscription_state,
connection_still_subscribed_to_thread,
thread_state,
)
};
pub(crate) async fn remove_listener(&mut self, subscription_id: Uuid) -> Option<ThreadId> {
let subscription_state = self.subscription_state_by_id.remove(&subscription_id)?;
let thread_id = subscription_state.thread_id;
if let Some((no_subscribers, thread_state)) = thread_state {
let thread_state = thread_state.lock().await;
if !connection_still_subscribed_to_thread && no_subscribers {
let connection_still_subscribed_to_thread =
self.subscription_state_by_id.values().any(|state| {
state.thread_id == thread_id
&& state.connection_id == subscription_state.connection_id
});
if !connection_still_subscribed_to_thread {
let mut remove_connection_entry = false;
if let Some(thread_ids) = self
.thread_ids_by_connection
.get_mut(&subscription_state.connection_id)
{
thread_ids.remove(&thread_id);
remove_connection_entry = thread_ids.is_empty();
}
if remove_connection_entry {
self.thread_ids_by_connection
.remove(&subscription_state.connection_id);
}
}
if let Some(thread_state) = self.thread_states.get(&thread_id) {
let mut thread_state = thread_state.lock().await;
if !connection_still_subscribed_to_thread {
thread_state.remove_connection(subscription_state.connection_id);
}
if thread_state.subscribed_connection_ids().is_empty() {
tracing::debug!(
thread_id = %thread_id,
subscription_id = %subscription_id,
@@ -240,24 +187,8 @@ impl ThreadStateManager {
Some(thread_id)
}
pub(crate) async fn remove_thread_state(&self, thread_id: ThreadId) {
let thread_state = {
let mut state = self.state.lock().await;
let thread_state = state
.threads
.remove(&thread_id)
.map(|thread_entry| thread_entry.state);
state
.subscription_state_by_id
.retain(|_, state| state.thread_id != thread_id);
state.thread_ids_by_connection.retain(|_, thread_ids| {
thread_ids.remove(&thread_id);
!thread_ids.is_empty()
});
thread_state
};
if let Some(thread_state) = thread_state {
pub(crate) async fn remove_thread_state(&mut self, thread_id: ThreadId) {
if let Some(thread_state) = self.thread_states.remove(&thread_id) {
let mut thread_state = thread_state.lock().await;
tracing::debug!(
thread_id = %thread_id,
@@ -268,189 +199,142 @@ impl ThreadStateManager {
);
thread_state.clear_listener();
}
self.subscription_state_by_id
.retain(|_, state| state.thread_id != thread_id);
self.thread_ids_by_connection.retain(|_, thread_ids| {
thread_ids.remove(&thread_id);
!thread_ids.is_empty()
});
}
pub(crate) async fn unsubscribe_connection_from_thread(
&self,
&mut self,
thread_id: ThreadId,
connection_id: ConnectionId,
) -> bool {
{
let mut state = self.state.lock().await;
if !state.threads.contains_key(&thread_id) {
return false;
}
if !state
.thread_ids_by_connection
.get(&connection_id)
.is_some_and(|thread_ids| thread_ids.contains(&thread_id))
{
return false;
}
if let Some(thread_ids) = state.thread_ids_by_connection.get_mut(&connection_id) {
thread_ids.remove(&thread_id);
if thread_ids.is_empty() {
state.thread_ids_by_connection.remove(&connection_id);
}
}
if let Some(thread_entry) = state.threads.get_mut(&thread_id) {
thread_entry.connection_ids.remove(&connection_id);
}
state
.subscription_state_by_id
.retain(|_, subscription_state| {
!(subscription_state.thread_id == thread_id
&& subscription_state.connection_id == connection_id)
});
let Some(thread_state) = self.thread_states.get(&thread_id) else {
return false;
};
if !self
.thread_ids_by_connection
.get(&connection_id)
.is_some_and(|thread_ids| thread_ids.contains(&thread_id))
{
return false;
}
if let Some(thread_ids) = self.thread_ids_by_connection.get_mut(&connection_id) {
thread_ids.remove(&thread_id);
if thread_ids.is_empty() {
self.thread_ids_by_connection.remove(&connection_id);
}
}
self.subscription_state_by_id.retain(|_, state| {
!(state.thread_id == thread_id && state.connection_id == connection_id)
});
let mut thread_state = thread_state.lock().await;
thread_state.remove_connection(connection_id);
true
}
pub(crate) async fn has_subscribers(&self, thread_id: ThreadId) -> bool {
self.state
let Some(thread_state) = self.thread_states.get(&thread_id) else {
return false;
};
!thread_state
.lock()
.await
.threads
.get(&thread_id)
.is_some_and(|thread_entry| !thread_entry.connection_ids.is_empty())
.subscribed_connection_ids()
.is_empty()
}
pub(crate) async fn set_listener(
&self,
&mut self,
subscription_id: Uuid,
thread_id: ThreadId,
connection_id: ConnectionId,
experimental_raw_events: bool,
) -> Arc<Mutex<ThreadState>> {
let thread_state = {
let mut state = self.state.lock().await;
state.subscription_state_by_id.insert(
subscription_id,
SubscriptionState {
thread_id,
connection_id,
},
);
state
.thread_ids_by_connection
.entry(connection_id)
.or_default()
.insert(thread_id);
let thread_entry = state.threads.entry(thread_id).or_default();
thread_entry.connection_ids.insert(connection_id);
thread_entry.state.clone()
};
self.subscription_state_by_id.insert(
subscription_id,
SubscriptionState {
thread_id,
connection_id,
},
);
self.thread_ids_by_connection
.entry(connection_id)
.or_default()
.insert(thread_id);
let thread_state = self.thread_state(thread_id);
{
let mut thread_state_guard = thread_state.lock().await;
thread_state_guard.add_connection(connection_id);
thread_state_guard.set_experimental_raw_events(experimental_raw_events);
}
thread_state
}
pub(crate) async fn try_ensure_connection_subscribed(
&self,
pub(crate) async fn ensure_connection_subscribed(
&mut self,
thread_id: ThreadId,
connection_id: ConnectionId,
experimental_raw_events: bool,
) -> Option<Arc<Mutex<ThreadState>>> {
let thread_state = {
let mut state = self.state.lock().await;
if !state.live_connections.contains(&connection_id) {
return None;
}
state
.thread_ids_by_connection
.entry(connection_id)
.or_default()
.insert(thread_id);
let thread_entry = state.threads.entry(thread_id).or_default();
thread_entry.connection_ids.insert(connection_id);
thread_entry.state.clone()
};
) -> Arc<Mutex<ThreadState>> {
self.thread_ids_by_connection
.entry(connection_id)
.or_default()
.insert(thread_id);
let thread_state = self.thread_state(thread_id);
{
let mut thread_state_guard = thread_state.lock().await;
thread_state_guard.add_connection(connection_id);
if experimental_raw_events {
thread_state_guard.set_experimental_raw_events(true);
}
}
Some(thread_state)
thread_state
}
pub(crate) async fn try_add_connection_to_thread(
&self,
thread_id: ThreadId,
connection_id: ConnectionId,
) -> bool {
let mut state = self.state.lock().await;
if !state.live_connections.contains(&connection_id) {
return false;
}
state
pub(crate) async fn remove_connection(&mut self, connection_id: ConnectionId) {
let thread_ids = self
.thread_ids_by_connection
.entry(connection_id)
.or_default()
.insert(thread_id);
state
.threads
.entry(thread_id)
.or_default()
.connection_ids
.insert(connection_id);
true
}
.remove(&connection_id)
.unwrap_or_default();
self.subscription_state_by_id
.retain(|_, state| state.connection_id != connection_id);
pub(crate) async fn remove_connection(&self, connection_id: ConnectionId) {
let thread_states = {
let mut state = self.state.lock().await;
state.live_connections.remove(&connection_id);
let thread_ids = state
.thread_ids_by_connection
.remove(&connection_id)
.unwrap_or_default();
state
.subscription_state_by_id
.retain(|_, state| state.connection_id != connection_id);
for thread_id in &thread_ids {
if let Some(thread_entry) = state.threads.get_mut(thread_id) {
thread_entry.connection_ids.remove(&connection_id);
if thread_ids.is_empty() {
for thread_state in self.thread_states.values() {
let mut thread_state = thread_state.lock().await;
thread_state.remove_connection(connection_id);
if thread_state.subscribed_connection_ids().is_empty() {
tracing::debug!(
connection_id = ?connection_id,
listener_generation = thread_state.listener_generation,
"retaining thread listener after connection disconnect left zero subscribers"
);
}
}
thread_ids
.into_iter()
.map(|thread_id| {
(
thread_id,
state
.threads
.get(&thread_id)
.is_none_or(|thread_entry| thread_entry.connection_ids.is_empty()),
state
.threads
.get(&thread_id)
.map(|thread_entry| thread_entry.state.clone()),
)
})
.collect::<Vec<_>>()
};
return;
}
for (thread_id, no_subscribers, thread_state) in thread_states {
if !no_subscribers {
continue;
for thread_id in thread_ids {
if let Some(thread_state) = self.thread_states.get(&thread_id) {
let mut thread_state = thread_state.lock().await;
thread_state.remove_connection(connection_id);
if thread_state.subscribed_connection_ids().is_empty() {
tracing::debug!(
thread_id = %thread_id,
connection_id = ?connection_id,
listener_generation = thread_state.listener_generation,
"retaining thread listener after connection disconnect left zero subscribers"
);
}
}
let Some(thread_state) = thread_state else {
continue;
};
let listener_generation = thread_state.lock().await.listener_generation;
tracing::debug!(
thread_id = %thread_id,
connection_id = ?connection_id,
listener_generation,
"retaining thread listener after connection disconnect left zero subscribers"
);
}
}
}

View File

@@ -91,12 +91,7 @@ impl ThreadWatchManager {
}
pub(crate) async fn upsert_thread(&self, thread: Thread) {
self.mutate_and_publish(move |state| state.upsert_thread(thread.id, true))
.await;
}
pub(crate) async fn upsert_thread_silently(&self, thread: Thread) {
self.mutate_and_publish(move |state| state.upsert_thread(thread.id, false))
self.mutate_and_publish(move |state| state.upsert_thread(thread.id))
.await;
}
@@ -294,22 +289,14 @@ struct ThreadWatchState {
}
impl ThreadWatchState {
fn upsert_thread(
&mut self,
thread_id: String,
emit_notification: bool,
) -> Option<ThreadStatusChangedNotification> {
fn upsert_thread(&mut self, thread_id: String) -> Option<ThreadStatusChangedNotification> {
let previous_status = self.status_for(&thread_id);
let runtime = self
.runtime_by_thread_id
.entry(thread_id.clone())
.or_default();
runtime.is_loaded = true;
if emit_notification {
self.status_changed_notification(thread_id, previous_status)
} else {
None
}
self.status_changed_notification(thread_id, previous_status)
}
fn remove_thread(&mut self, thread_id: &str) -> Option<ThreadStatusChangedNotification> {
@@ -705,45 +692,6 @@ mod tests {
);
}
#[tokio::test]
async fn silent_upsert_skips_initial_notification() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8);
let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new(
outgoing_tx,
)));
manager
.upsert_thread_silently(test_thread(
INTERACTIVE_THREAD_ID,
codex_app_server_protocol::SessionSource::Cli,
))
.await;
assert_eq!(
manager
.loaded_status_for_thread(INTERACTIVE_THREAD_ID)
.await,
ThreadStatus::Idle,
);
assert!(
timeout(Duration::from_millis(100), outgoing_rx.recv())
.await
.is_err(),
"silent upsert should not emit thread/status/changed"
);
manager.note_turn_started(INTERACTIVE_THREAD_ID).await;
assert_eq!(
recv_status_changed_notification(&mut outgoing_rx).await,
ThreadStatusChangedNotification {
thread_id: INTERACTIVE_THREAD_ID.to_string(),
status: ThreadStatus::Active {
active_flags: vec![],
},
},
);
}
async fn wait_for_status(
manager: &ThreadWatchManager,
thread_id: &str,
@@ -785,7 +733,6 @@ mod tests {
Thread {
id: thread_id.to_string(),
preview: String::new(),
ephemeral: false,
model_provider: "mock-provider".to_string(),
created_at: 0,
updated_at: 0,

View File

@@ -744,7 +744,6 @@ mod tests {
id: codex_app_server_protocol::RequestId::Integer(7),
method: "config/read".to_string(),
params: Some(json!({ "includeLayers": false })),
trace: None,
});
assert!(
enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request).await
@@ -886,7 +885,6 @@ mod tests {
id: codex_app_server_protocol::RequestId::Integer(7),
method: "config/read".to_string(),
params: Some(json!({ "includeLayers": false })),
trace: None,
});
let enqueue_result = tokio::time::timeout(

View File

@@ -891,7 +891,6 @@ impl McpProcess {
id: RequestId::Integer(request_id),
method: method.to_string(),
params,
trace: None,
});
self.send_jsonrpc_message(message).await?;
Ok(request_id)

View File

@@ -84,7 +84,6 @@ pub fn create_fake_rollout_with_source(
model_provider: model_provider.map(str::to_string),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
};
let payload = serde_json::to_value(SessionMetaLine {
meta,
@@ -166,7 +165,6 @@ pub fn create_fake_rollout_with_text_elements(
model_provider: model_provider.map(str::to_string),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
};
let payload = serde_json::to_value(SessionMetaLine {
meta,

View File

@@ -36,7 +36,7 @@ use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_codex_jsonrpc_conversation_flow() -> Result<()> {
@@ -337,7 +337,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
service_tier: None,
output_schema: None,
})
.await?;
@@ -454,7 +453,6 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
model: model.clone(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
service_tier: None,
output_schema: None,
})
.await?;
@@ -483,7 +481,6 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
model: model.clone(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
service_tier: None,
output_schema: None,
})
.await?;

View File

@@ -92,7 +92,6 @@ async fn send_user_turn_accepts_output_schema_v1() -> Result<()> {
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
service_tier: None,
output_schema: Some(output_schema.clone()),
})
.await?;
@@ -185,7 +184,6 @@ async fn send_user_turn_rejects_oversized_input_v1() -> Result<()> {
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Low),
summary: ReasoningSummary::Auto,
service_tier: None,
output_schema: None,
})
.await?;
@@ -275,7 +273,6 @@ async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> {
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
service_tier: None,
output_schema: Some(output_schema.clone()),
})
.await?;
@@ -324,7 +321,6 @@ async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> {
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
service_tier: None,
output_schema: None,
})
.await?;

View File

@@ -628,7 +628,6 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std
model: model.to_string(),
personality: None,
collaboration_mode: None,
realtime_active: Some(false),
effort: None,
summary: ReasoningSummary::Auto,
user_instructions: None,

View File

@@ -131,7 +131,6 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> {
payload.auth_mode.is_none(),
"auth_method should be None after logout"
);
assert_eq!(payload.plan_type, None);
assert!(
!codex_home.path().join("auth.json").exists(),
@@ -202,7 +201,6 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
bail!("unexpected notification: {parsed:?}");
};
assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens));
assert_eq!(payload.plan_type, Some(AccountPlanType::Pro));
let get_id = mcp
.send_get_account_request(GetAccountParams {
@@ -845,7 +843,6 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
bail!("unexpected notification: {parsed:?}");
};
pretty_assertions::assert_eq!(payload.auth_mode, Some(AuthMode::ApiKey));
pretty_assertions::assert_eq!(payload.plan_type, None);
assert!(codex_home.path().join("auth.json").exists());
Ok(())
@@ -1230,45 +1227,3 @@ async fn get_account_with_chatgpt() -> Result<()> {
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt").email("user@example.com"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Unknown,
}),
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}

View File

@@ -30,7 +30,7 @@ async fn app_server_default_analytics_disabled_without_flag() -> Result<()> {
let provider = codex_core::otel_init::build_provider(
&config,
SERVICE_VERSION,
Some("codex-app-server"),
Some("codex_app_server"),
false,
)
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
@@ -55,7 +55,7 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> {
let provider = codex_core::otel_init::build_provider(
&config,
SERVICE_VERSION,
Some("codex-app-server"),
Some("codex_app_server"),
true,
)
.map_err(|err| anyhow::anyhow!(err.to_string()))?;

View File

@@ -428,7 +428,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
}
#[tokio::test]
async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() -> Result<()> {
async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
let connectors = vec![
AppInfo {
id: "alpha".to_string(),
@@ -475,7 +475,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-directory-first")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
@@ -492,14 +492,60 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
})
.await?;
let maybe_update = timeout(
Duration::from_millis(150),
read_app_list_updated_notification(&mut mcp),
)
.await;
let expected_directory_first = vec![
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
name: "beta".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: false,
is_enabled: true,
},
];
let expected_accessible_first = vec![AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
}];
let first_update = read_app_list_updated_notification(&mut mcp).await?;
// app/list emits an update after whichever async load finishes first. Even with
// a tools delay in this test, the accessible-tools path can return first if the
// process-global Codex Apps tools cache is warm from another test.
assert!(
maybe_update.is_err(),
"unexpected directory-only app/list update before accessible apps loaded"
first_update.data == expected_directory_first
|| first_update.data == expected_accessible_first,
"unexpected first app/list update: {:#?}",
first_update.data
);
let expected = vec![
@@ -533,96 +579,8 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
},
];
let update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(update.data, expected);
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert_eq!(data, expected);
assert!(next_cursor.is_none());
server_handle.abort();
Ok(())
}
#[tokio::test]
async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
let connectors = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}];
let (server_url, server_handle) = start_apps_server_with_delays(
connectors.clone(),
Vec::new(),
Duration::from_millis(300),
Duration::ZERO,
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-empty-interim")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: false,
})
.await?;
let maybe_update = timeout(
Duration::from_millis(150),
read_app_list_updated_notification(&mut mcp),
)
.await;
assert!(
maybe_update.is_err(),
"unexpected empty interim app/list update"
);
let expected = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
}];
let update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(update.data, expected);
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(second_update.data, expected);
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
@@ -1068,14 +1026,39 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
]
);
let maybe_second_update = timeout(
Duration::from_millis(150),
read_app_list_updated_notification(&mut mcp),
)
.await;
assert!(
maybe_second_update.is_err(),
"unexpected inaccessible-only app/list update during force refetch"
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
second_update.data,
vec![
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: Some("Beta v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: false,
is_enabled: true,
},
]
);
let expected_final = vec![AppInfo {
@@ -1092,8 +1075,8 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
is_accessible: false,
is_enabled: true,
}];
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(second_update.data, expected_final);
let third_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(third_update.data, expected_final);
let refetch_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,

View File

@@ -174,7 +174,6 @@ pub(super) async fn send_request(
id: RequestId::Integer(id),
method: method.to_string(),
params,
trace: None,
});
send_jsonrpc(stream, message).await
}

View File

@@ -5,7 +5,6 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
@@ -44,16 +43,19 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let realtime_server = start_websocket_server(vec![vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_backend", "instructions": "backend prompt" }
"type": "session.created",
"session": { "id": "sess_backend" }
})],
vec![json!({
"type": "session.updated",
"session": { "backend_prompt": "backend prompt" }
})],
vec![],
vec![
json!({
"type": "conversation.output_audio.delta",
"type": "response.output_audio.delta",
"delta": "AQID",
"sample_rate": 24_000,
"channels": 1,
"num_channels": 1,
"samples_per_channel": 512
}),
json!({
@@ -82,7 +84,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
mcp.initialize().await?;
login_with_api_key(&mut mcp, "sk-test-key").await?;
let thread_start_request_id = mcp
.send_thread_start_request(ThreadStartParams::default())
@@ -181,7 +182,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
assert_eq!(connection.len(), 3);
assert_eq!(
connection[0].body_json()["type"].as_str(),
Some("session.update")
Some("session.create")
);
let mut request_types = [
connection[1].body_json()["type"]
@@ -198,7 +199,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
request_types,
[
"conversation.item.create".to_string(),
"input_audio_buffer.append".to_string(),
"response.input_audio.delta".to_string(),
]
);
@@ -213,8 +214,8 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let realtime_server = start_websocket_server(vec![vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_backend", "instructions": "backend prompt" }
"type": "session.created",
"session": { "id": "sess_backend" }
})],
vec![],
]])
@@ -230,7 +231,6 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
mcp.initialize().await?;
login_with_api_key(&mut mcp, "sk-test-key").await?;
let thread_start_request_id = mcp
.send_thread_start_request(ThreadStartParams::default())
@@ -349,22 +349,6 @@ async fn read_notification<T: DeserializeOwned>(mcp: &mut McpProcess, method: &s
Ok(serde_json::from_value(params)?)
}
async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> {
let request_id = mcp
.send_login_api_key_request(LoginApiKeyParams {
api_key: api_key.to_string(),
})
.await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
Ok(())
}
fn create_config_toml(
codex_home: &Path,
responses_server_uri: &str,

View File

@@ -4,11 +4,9 @@ use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_request_user_input_sse_response;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -88,7 +86,6 @@ async fn request_user_input_round_trip() -> Result<()> {
assert_eq!(params.turn_id, turn.id);
assert_eq!(params.item_id, "call1");
assert_eq!(params.questions.len(), 1);
let resolved_request_id = request_id.clone();
mcp.send_response(
request_id,
@@ -99,31 +96,17 @@ async fn request_user_input_round_trip() -> Result<()> {
}),
)
.await?;
let mut saw_resolved = false;
loop {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
match notification.method.as_str() {
"serverRequest/resolved" => {
let resolved: ServerRequestResolvedNotification = serde_json::from_value(
notification
.params
.clone()
.expect("serverRequest/resolved params"),
)?;
assert_eq!(resolved.thread_id, thread.id);
assert_eq!(resolved.request_id, resolved_request_id);
saw_resolved = true;
}
"turn/completed" => {
assert!(saw_resolved, "serverRequest/resolved should arrive first");
break;
}
_ => {}
}
}
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}

View File

@@ -8,7 +8,6 @@ use app_test_support::to_response;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
@@ -20,12 +19,9 @@ use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -305,31 +301,6 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<(
"detached review should run on a different thread"
);
let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT;
let notification = loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
let message = timeout(remaining, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
if notification.method == "thread/status/changed" {
let status_changed: ThreadStatusChangedNotification =
serde_json::from_value(notification.params.expect("params must be present"))?;
if status_changed.thread_id == review_thread_id {
anyhow::bail!(
"detached review threads should be introduced without a preceding thread/status/changed"
);
}
continue;
}
if notification.method == "thread/started" {
break notification;
}
};
let started: ThreadStartedNotification =
serde_json::from_value(notification.params.expect("params must be present"))?;
assert_eq!(started.thread.id, review_thread_id);
Ok(())
}
@@ -418,11 +389,6 @@ async fn start_default_thread(mcp: &mut McpProcess) -> Result<String> {
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
Ok(thread.id)
}

View File

@@ -4,7 +4,7 @@ use app_test_support::create_fake_rollout;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
@@ -15,7 +15,6 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use pretty_assertions::assert_eq;
@@ -125,27 +124,11 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
}
// A corresponding thread/started notification should arrive.
let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT;
let notif = loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
let message = timeout(remaining, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notif) = message else {
continue;
};
if notif.method == "thread/status/changed" {
let status_changed: ThreadStatusChangedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
if status_changed.thread_id == thread.id {
anyhow::bail!(
"thread/fork should introduce the thread without a preceding thread/status/changed"
);
}
continue;
}
if notif.method == "thread/started" {
break notif;
}
};
let notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
let started_params = notif.params.clone().expect("params must be present");
let started_thread_json = started_params
.get("thread")

View File

@@ -10,7 +10,6 @@ use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
@@ -79,7 +78,6 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> {
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.preview, preview);
assert_eq!(thread.model_provider, "mock_provider");
assert!(!thread.ephemeral, "stored rollouts should not be ephemeral");
assert!(thread.path.as_ref().expect("thread path").is_absolute());
assert_eq!(thread.cwd, PathBuf::from("/"));
assert_eq!(thread.cli_version, "0.0.0");
@@ -221,6 +219,25 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
// `thread/name/set` operates on loaded threads (via ThreadManager). A rollout existing on disk
// is not enough; we must `thread/resume` first to load it into the running server.
let pre_resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id.clone(),
..Default::default()
})
.await?;
let pre_resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(pre_resume_id)),
)
.await??;
let ThreadResumeResponse {
thread: pre_resumed,
..
} = to_response::<ThreadResumeResponse>(pre_resume_resp)?;
assert_eq!(pre_resumed.id, conversation_id);
// Set a user-facing thread title.
let new_name = "My renamed thread";
let set_id = mcp
@@ -235,15 +252,6 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
)
.await??;
let _: ThreadSetNameResponse = to_response::<ThreadSetNameResponse>(set_resp)?;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/name/updated"),
)
.await??;
let notification: ThreadNameUpdatedNotification =
serde_json::from_value(notification.params.expect("thread/name/updated params"))?;
assert_eq!(notification.thread_id, conversation_id);
assert_eq!(notification.thread_name.as_deref(), Some(new_name));
// Read should now surface `thread.name`, and the wire payload must include `name`.
let read_id = mcp
@@ -270,11 +278,6 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
Some(new_name),
"thread/read must serialize `thread.name` on the wire"
);
assert_eq!(
thread_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
"thread/read must serialize `thread.ephemeral` on the wire"
);
// List should also surface the name.
let list_id = mcp
@@ -314,11 +317,6 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
Some(new_name),
"thread/list must serialize `thread.name` on the wire"
);
assert_eq!(
listed_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
"thread/list must serialize `thread.ephemeral` on the wire"
);
// Resume should also surface the name.
let resume_id = mcp
@@ -347,11 +345,6 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
Some(new_name),
"thread/resume must serialize `thread.name` on the wire"
);
assert_eq!(
resumed_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
"thread/resume must serialize `thread.ephemeral` on the wire"
);
Ok(())
}

View File

@@ -1,26 +1,13 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_apply_patch_sse_response;
use app_test_support::create_fake_rollout_with_text_elements;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_shell_command_sse_response;
use app_test_support::rollout_path;
use app_test_support::to_response;
use chrono::Utc;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadResumeParams;
@@ -652,306 +639,6 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R
Ok(())
}
#[tokio::test]
async fn thread_resume_replays_pending_command_execution_request_approval() -> Result<()> {
let responses = vec![
create_final_assistant_message_sse_response("seeded")?,
create_shell_command_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
None,
Some(5000),
"call-1",
)?,
create_final_assistant_message_sse_response("done")?,
];
let server = create_mock_responses_server_sequence(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut primary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??;
let start_id = primary
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let seed_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "seed history".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
primary.clear_message_buffer();
let running_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "run command".to_string(),
text_elements: Vec::new(),
}],
approval_policy: Some(AskForApproval::UnlessTrusted),
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
)
.await??;
let original_request = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_request_message(),
)
.await??;
let ServerRequest::CommandExecutionRequestApproval { .. } = &original_request else {
panic!("expected CommandExecutionRequestApproval request, got {original_request:?}");
};
let resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id.clone(),
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse {
thread: resumed_thread,
..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resumed_thread.id, thread.id);
assert!(
resumed_thread
.turns
.iter()
.any(|turn| matches!(turn.status, TurnStatus::InProgress))
);
let replayed_request = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_request_message(),
)
.await??;
pretty_assertions::assert_eq!(replayed_request, original_request);
let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = replayed_request else {
panic!("expected CommandExecutionRequestApproval request");
};
primary
.send_response(
request_id,
serde_json::to_value(CommandExecutionRequestApprovalResponse {
decision: CommandExecutionApprovalDecision::Accept,
})?,
)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn thread_resume_replays_pending_file_change_request_approval() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let patch = r#"*** Begin Patch
*** Add File: README.md
+new line
*** End Patch
"#;
let responses = vec![
create_final_assistant_message_sse_response("seeded")?,
create_apply_patch_sse_response(patch, "patch-call")?,
create_final_assistant_message_sse_response("done")?,
];
let server = create_mock_responses_server_sequence(responses).await;
create_config_toml(&codex_home, &server.uri())?;
let mut primary = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??;
let start_id = primary
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
cwd: Some(workspace.to_string_lossy().into_owned()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let seed_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "seed history".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(workspace.clone()),
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
primary.clear_message_buffer();
let running_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "apply patch".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(workspace.clone()),
approval_policy: Some(AskForApproval::UnlessTrusted),
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
)
.await??;
let original_started = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let notification = primary
.read_stream_until_notification_message("item/started")
.await?;
let started: ItemStartedNotification =
serde_json::from_value(notification.params.clone().expect("item/started params"))?;
if let ThreadItem::FileChange { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
}
}
})
.await??;
let expected_readme_path = workspace.join("README.md");
let expected_file_change = ThreadItem::FileChange {
id: "patch-call".to_string(),
changes: vec![codex_app_server_protocol::FileUpdateChange {
path: expected_readme_path.to_string_lossy().into_owned(),
kind: PatchChangeKind::Add,
diff: "new line\n".to_string(),
}],
status: PatchApplyStatus::InProgress,
};
assert_eq!(original_started, expected_file_change);
let original_request = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_request_message(),
)
.await??;
let ServerRequest::FileChangeRequestApproval { .. } = &original_request else {
panic!("expected FileChangeRequestApproval request, got {original_request:?}");
};
primary.clear_message_buffer();
let resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id.clone(),
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse {
thread: resumed_thread,
..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resumed_thread.id, thread.id);
assert!(
resumed_thread
.turns
.iter()
.any(|turn| matches!(turn.status, TurnStatus::InProgress))
);
let replayed_request = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_request_message(),
)
.await??;
assert_eq!(replayed_request, original_request);
let ServerRequest::FileChangeRequestApproval { request_id, .. } = replayed_request else {
panic!("expected FileChangeRequestApproval request");
};
primary
.send_response(
request_id,
serde_json::to_value(FileChangeRequestApprovalResponse {
decision: FileChangeApprovalDecision::Accept,
})?,
)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;

View File

@@ -3,18 +3,16 @@ use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::path::Path;
use tempfile::TempDir;
@@ -64,10 +62,6 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
thread.created_at > 0,
"created_at should be a positive UNIX timestamp"
);
assert!(
!thread.ephemeral,
"new persistent threads should not be ephemeral"
);
assert_eq!(thread.status, ThreadStatus::Idle);
let thread_path = thread.path.clone().expect("thread path should be present");
assert!(thread_path.is_absolute(), "thread path should be absolute");
@@ -86,35 +80,14 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Some(&Value::Null),
"new threads should serialize `name: null`"
);
assert_eq!(
thread_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
"new persistent threads should serialize `ephemeral: false`"
);
assert_eq!(thread.name, None);
// A corresponding thread/started notification should arrive.
let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT;
let notif = loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
let message = timeout(remaining, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notif) = message else {
continue;
};
if notif.method == "thread/status/changed" {
let status_changed: ThreadStatusChangedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
if status_changed.thread_id == thread.id {
anyhow::bail!(
"thread/start should introduce the thread without a preceding thread/status/changed"
);
}
continue;
}
if notif.method == "thread/started" {
break notif;
}
};
let notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
let started_params = notif.params.clone().expect("params must be present");
let started_thread_json = started_params
.get("thread")
@@ -125,13 +98,6 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Some(&Value::Null),
"thread/started should serialize `name: null` for new threads"
);
assert_eq!(
started_thread_json
.get("ephemeral")
.and_then(Value::as_bool),
Some(false),
"thread/started should serialize `ephemeral: false` for new persistent threads"
);
let started: ThreadStartedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
assert_eq!(started.thread, thread);
@@ -230,25 +196,11 @@ async fn thread_start_ephemeral_remains_pathless() -> Result<()> {
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let resp_result = resp.result.clone();
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(resp)?;
assert!(
thread.ephemeral,
"ephemeral threads should be marked explicitly"
);
assert_eq!(
thread.path, None,
"ephemeral threads should not expose a path"
);
let thread_json = resp_result
.get("thread")
.and_then(Value::as_object)
.expect("thread/start result.thread must be an object");
assert_eq!(
thread_json.get("ephemeral").and_then(Value::as_bool),
Some(true),
"ephemeral threads should serialize `ephemeral: true`"
);
Ok(())
}

View File

@@ -8,8 +8,6 @@ use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
@@ -50,7 +48,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> {
"call_sleep",
)?])
.await;
create_config_toml(&codex_home, &server.uri(), "never")?;
create_config_toml(&codex_home, &server.uri())?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -122,134 +120,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> {
#[cfg(target_os = "windows")]
let shell_command = vec![
"powershell".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 10".to_string(),
];
#[cfg(not(target_os = "windows"))]
let shell_command = vec!["sleep".to_string(), "10".to_string()];
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep_approval",
)?])
.await;
create_config_toml(&codex_home, &server.uri(), "untrusted")?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "run sleep".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(working_directory),
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let request = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::CommandExecutionRequestApproval { request_id, params } = request else {
panic!("expected CommandExecutionRequestApproval request");
};
assert_eq!(params.item_id, "call_sleep_approval");
assert_eq!(params.thread_id, thread.id);
assert_eq!(params.turn_id, turn.id);
let interrupt_id = mcp
.send_turn_interrupt_request(TurnInterruptParams {
thread_id: thread.id.clone(),
turn_id: turn.id.clone(),
})
.await?;
let interrupt_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)),
)
.await??;
let _resp: TurnInterruptResponse = to_response::<TurnInterruptResponse>(interrupt_resp)?;
let resolved_notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("serverRequest/resolved"),
)
.await??;
let resolved: ServerRequestResolvedNotification = serde_json::from_value(
resolved_notification
.params
.clone()
.expect("serverRequest/resolved params must be present"),
)?;
assert_eq!(resolved.thread_id, thread.id);
assert_eq!(resolved.request_id, request_id);
let completed_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let completed: TurnCompletedNotification = serde_json::from_value(
completed_notif
.params
.expect("turn/completed params must be present"),
)?;
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.status, TurnStatus::Interrupted);
Ok(())
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(
codex_home: &std::path::Path,
server_uri: &str,
approval_policy: &str,
) -> std::io::Result<()> {
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "{approval_policy}"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"

View File

@@ -22,14 +22,12 @@ use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::TextElement;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
@@ -434,21 +432,6 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
started.turn.status,
codex_app_server_protocol::TurnStatus::InProgress
);
assert_eq!(started.turn.id, turn.id);
let completed_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let completed: TurnCompletedNotification = serde_json::from_value(
completed_notif
.params
.expect("turn/completed params must be present"),
)?;
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.id, turn.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
// Send a second turn that exercises the overrides path: change the model.
let turn_req2 = mcp
@@ -472,30 +455,25 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
// Ensure the second turn has a different id than the first.
assert_ne!(turn.id, turn2.id);
let notif2: JSONRPCNotification = timeout(
// Expect a second turn/started notification as well.
let _notif2: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
let started2: TurnStartedNotification =
serde_json::from_value(notif2.params.expect("params must be present"))?;
assert_eq!(started2.thread_id, thread.id);
assert_eq!(started2.turn.id, turn2.id);
assert_eq!(started2.turn.status, TurnStatus::InProgress);
let completed_notif2: JSONRPCNotification = timeout(
let completed_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let completed2: TurnCompletedNotification = serde_json::from_value(
completed_notif2
let completed: TurnCompletedNotification = serde_json::from_value(
completed_notif
.params
.expect("turn/completed params must be present"),
)?;
assert_eq!(completed2.thread_id, thread.id);
assert_eq!(completed2.turn.id, turn2.id);
assert_eq!(completed2.turn.status, TurnStatus::Completed);
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
Ok(())
}
@@ -1093,7 +1071,6 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
panic!("expected CommandExecutionRequestApproval request");
};
assert_eq!(params.item_id, "call1");
let resolved_request_id = request_id.clone();
// Approve and wait for task completion
mcp.send_response(
@@ -1103,31 +1080,16 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
})?,
)
.await?;
let mut saw_resolved = false;
loop {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
match notification.method.as_str() {
"serverRequest/resolved" => {
let resolved: ServerRequestResolvedNotification = serde_json::from_value(
notification
.params
.clone()
.expect("serverRequest/resolved params"),
)?;
assert_eq!(resolved.thread_id, thread.id);
assert_eq!(resolved.request_id, resolved_request_id);
saw_resolved = true;
}
"turn/completed" => {
assert!(saw_resolved, "serverRequest/resolved should arrive first");
break;
}
_ => {}
}
}
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
// Second turn with approval_policy=never should not elicit approval
let second_turn_id = mcp
@@ -1385,7 +1347,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
service_tier: None,
personality: None,
output_schema: None,
collaboration_mode: None,
@@ -1417,7 +1378,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
service_tier: None,
personality: None,
output_schema: None,
collaboration_mode: None,
@@ -1567,7 +1527,6 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
assert_eq!(params.item_id, "patch-call");
assert_eq!(params.thread_id, thread.id);
assert_eq!(params.turn_id, turn.id);
let resolved_request_id = request_id.clone();
let expected_readme_path = workspace.join("README.md");
let expected_readme_path = expected_readme_path.to_string_lossy().into_owned();
pretty_assertions::assert_eq!(
@@ -1586,49 +1545,18 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
})?,
)
.await?;
let mut saw_resolved = false;
let mut output_delta: Option<FileChangeOutputDeltaNotification> = None;
let mut completed_file_change: Option<ThreadItem> = None;
while !(output_delta.is_some() && completed_file_change.is_some()) {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
match notification.method.as_str() {
"serverRequest/resolved" => {
let resolved: ServerRequestResolvedNotification = serde_json::from_value(
notification
.params
.clone()
.expect("serverRequest/resolved params"),
)?;
assert_eq!(resolved.thread_id, thread.id);
assert_eq!(resolved.request_id, resolved_request_id);
saw_resolved = true;
}
"item/fileChange/outputDelta" => {
assert!(saw_resolved, "serverRequest/resolved should arrive first");
let notification: FileChangeOutputDeltaNotification = serde_json::from_value(
notification
.params
.clone()
.expect("item/fileChange/outputDelta params"),
)?;
output_delta = Some(notification);
}
"item/completed" => {
let completed: ItemCompletedNotification = serde_json::from_value(
notification.params.clone().expect("item/completed params"),
)?;
if let ThreadItem::FileChange { .. } = completed.item {
assert!(saw_resolved, "serverRequest/resolved should arrive first");
completed_file_change = Some(completed.item);
}
}
_ => {}
}
}
let output_delta = output_delta.expect("file change output delta should be observed");
let output_delta_notif = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
)
.await??;
let output_delta: FileChangeOutputDeltaNotification = serde_json::from_value(
output_delta_notif
.params
.clone()
.expect("item/fileChange/outputDelta params"),
)?;
assert_eq!(output_delta.thread_id, thread.id);
assert_eq!(output_delta.turn_id, turn.id);
assert_eq!(output_delta.item_id, "patch-call");
@@ -1638,23 +1566,38 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
output_delta.delta
);
let completed_file_change =
completed_file_change.expect("file change completion should be observed");
let completed_file_change = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let completed_notif = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification = serde_json::from_value(
completed_notif
.params
.clone()
.expect("item/completed params"),
)?;
if let ThreadItem::FileChange { .. } = completed.item {
return Ok::<ThreadItem, anyhow::Error>(completed.item);
}
}
})
.await??;
let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else {
unreachable!("loop ensures we break on file change items");
};
assert_eq!(id, "patch-call");
assert_eq!(status, PatchApplyStatus::Completed);
let readme_contents = std::fs::read_to_string(expected_readme_path)?;
assert_eq!(readme_contents, "new line\n");
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
let readme_contents = std::fs::read_to_string(expected_readme_path)?;
assert_eq!(readme_contents, "new line\n");
Ok(())
}

View File

@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "artifact-presentation",
crate_name = "codex_artifact_presentation",
)

View File

@@ -1,28 +0,0 @@
[package]
name = "codex-artifact-presentation"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_artifact_presentation"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
base64 = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
ppt-rs = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tiny_http = { workspace = true }

View File

@@ -1,6 +0,0 @@
mod presentation_artifact;
#[cfg(test)]
mod tests;
pub use presentation_artifact::*;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "artifact-spreadsheet",
crate_name = "codex_artifact_spreadsheet",
)

View File

@@ -1,25 +0,0 @@
[package]
name = "codex-artifact-spreadsheet"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_artifact_spreadsheet"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
base64 = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -1,245 +0,0 @@
use serde::Deserialize;
use serde::Serialize;
use crate::SpreadsheetArtifactError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CellAddress {
pub column: u32,
pub row: u32,
}
impl CellAddress {
pub fn parse(address: &str) -> Result<Self, SpreadsheetArtifactError> {
let trimmed = address.trim();
if trimmed.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "address is empty".to_string(),
});
}
let mut split = 0usize;
for (index, ch) in trimmed.char_indices() {
if ch.is_ascii_alphabetic() {
split = index + ch.len_utf8();
} else {
break;
}
}
let (letters, digits) = trimmed.split_at(split);
if letters.is_empty() || digits.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "expected A1-style address".to_string(),
});
}
if !letters.chars().all(|ch| ch.is_ascii_alphabetic())
|| !digits.chars().all(|ch| ch.is_ascii_digit())
{
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "expected letters followed by digits".to_string(),
});
}
let column = column_letters_to_index(letters)?;
let row = digits
.parse::<u32>()
.map_err(|_| SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "row must be a positive integer".to_string(),
})?;
if row == 0 {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "row must be positive".to_string(),
});
}
Ok(Self { column, row })
}
pub fn to_a1(self) -> String {
format!("{}{}", column_index_to_letters(self.column), self.row)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CellRange {
pub start: CellAddress,
pub end: CellAddress,
}
impl CellRange {
pub fn parse(address: &str) -> Result<Self, SpreadsheetArtifactError> {
let trimmed = address.trim();
if trimmed.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "range is empty".to_string(),
});
}
let (start, end) = if let Some((left, right)) = trimmed.split_once(':') {
(CellAddress::parse(left)?, CellAddress::parse(right)?)
} else {
let cell = CellAddress::parse(trimmed)?;
(cell, cell)
};
let normalized = Self {
start: CellAddress {
column: start.column.min(end.column),
row: start.row.min(end.row),
},
end: CellAddress {
column: start.column.max(end.column),
row: start.row.max(end.row),
},
};
Ok(normalized)
}
pub fn from_start_end(start: CellAddress, end: CellAddress) -> Self {
Self {
start: CellAddress {
column: start.column.min(end.column),
row: start.row.min(end.row),
},
end: CellAddress {
column: start.column.max(end.column),
row: start.row.max(end.row),
},
}
}
pub fn to_a1(&self) -> String {
if self.is_single_cell() {
self.start.to_a1()
} else {
format!("{}:{}", self.start.to_a1(), self.end.to_a1())
}
}
pub fn is_single_cell(&self) -> bool {
self.start == self.end
}
pub fn is_single_row(&self) -> bool {
self.start.row == self.end.row
}
pub fn is_single_column(&self) -> bool {
self.start.column == self.end.column
}
pub fn width(&self) -> usize {
(self.end.column - self.start.column + 1) as usize
}
pub fn height(&self) -> usize {
(self.end.row - self.start.row + 1) as usize
}
pub fn contains(&self, address: CellAddress) -> bool {
self.start.column <= address.column
&& address.column <= self.end.column
&& self.start.row <= address.row
&& address.row <= self.end.row
}
pub fn contains_range(&self, other: &CellRange) -> bool {
self.contains(other.start) && self.contains(other.end)
}
pub fn intersects(&self, other: &CellRange) -> bool {
!(self.end.column < other.start.column
|| other.end.column < self.start.column
|| self.end.row < other.start.row
|| other.end.row < self.start.row)
}
pub fn addresses(&self) -> impl Iterator<Item = CellAddress> {
let range = self.clone();
(range.start.row..=range.end.row).flat_map(move |row| {
let range = range.clone();
(range.start.column..=range.end.column).map(move |column| CellAddress { column, row })
})
}
}
pub fn column_letters_to_index(column: &str) -> Result<u32, SpreadsheetArtifactError> {
let trimmed = column.trim();
if trimmed.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: column.to_string(),
message: "column is empty".to_string(),
});
}
let mut result = 0u32;
for ch in trimmed.chars() {
if !ch.is_ascii_alphabetic() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: column.to_string(),
message: "column must contain only letters".to_string(),
});
}
result = result
.checked_mul(26)
.and_then(|value| value.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as u32))
.ok_or_else(|| SpreadsheetArtifactError::InvalidAddress {
address: column.to_string(),
message: "column is too large".to_string(),
})?;
}
Ok(result)
}
pub fn column_index_to_letters(mut index: u32) -> String {
if index == 0 {
return String::new();
}
let mut letters = Vec::new();
while index > 0 {
let remainder = (index - 1) % 26;
letters.push((b'A' + remainder as u8) as char);
index = (index - 1) / 26;
}
letters.iter().rev().collect()
}
pub fn parse_column_reference(reference: &str) -> Result<(u32, u32), SpreadsheetArtifactError> {
let trimmed = reference.trim();
if let Some((left, right)) = trimmed.split_once(':') {
let start = column_letters_to_index(left)?;
let end = column_letters_to_index(right)?;
Ok((start.min(end), start.max(end)))
} else {
let column = column_letters_to_index(trimmed)?;
Ok((column, column))
}
}
pub fn is_valid_cell_reference(address: &str) -> bool {
CellAddress::parse(address).is_ok()
}
pub fn is_valid_range_reference(address: &str) -> bool {
CellRange::parse(address).is_ok()
}
pub fn is_valid_row_reference(address: &str) -> bool {
CellRange::parse(address)
.map(|range| range.is_single_row())
.unwrap_or(false)
}
pub fn is_valid_column_reference(address: &str) -> bool {
parse_column_reference(address).is_ok()
}

View File

@@ -1,39 +0,0 @@
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SpreadsheetArtifactError {
#[error("missing `artifact_id` for action `{action}`")]
MissingArtifactId { action: String },
#[error("unknown artifact id `{artifact_id}` for action `{action}`")]
UnknownArtifactId { action: String, artifact_id: String },
#[error("unknown action `{0}`")]
UnknownAction(String),
#[error("invalid args for action `{action}`: {message}")]
InvalidArgs { action: String, message: String },
#[error("invalid address `{address}`: {message}")]
InvalidAddress { address: String, message: String },
#[error("sheet lookup failed for action `{action}`: {message}")]
SheetLookup { action: String, message: String },
#[error("index `{index}` is out of range for action `{action}`; len={len}")]
IndexOutOfRange {
action: String,
index: usize,
len: usize,
},
#[error("merge conflict for action `{action}` on range `{range}` with `{conflict}`")]
MergeConflict {
action: String,
range: String,
conflict: String,
},
#[error("formula error at `{location}`: {message}")]
Formula { location: String, message: String },
#[error("serialization failed: {message}")]
Serialization { message: String },
#[error("failed to import XLSX `{path}`: {message}")]
ImportFailed { path: PathBuf, message: String },
#[error("failed to export XLSX `{path}`: {message}")]
ExportFailed { path: PathBuf, message: String },
}

View File

@@ -1,535 +0,0 @@
use std::collections::BTreeSet;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetCellValue;
#[derive(Debug, Clone)]
enum Token {
Number(f64),
Cell(String),
Ident(String),
Plus,
Minus,
Star,
Slash,
LParen,
RParen,
Colon,
Comma,
}
#[derive(Debug, Clone)]
enum Expr {
Number(f64),
Cell(CellAddress),
Range(CellRange),
UnaryMinus(Box<Expr>),
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
Function {
name: String,
args: Vec<Expr>,
},
}
#[derive(Debug, Clone, Copy)]
enum BinaryOp {
Add,
Subtract,
Multiply,
Divide,
}
#[derive(Debug, Clone)]
enum EvalValue {
Scalar(Option<SpreadsheetCellValue>),
Range(Vec<Option<SpreadsheetCellValue>>),
}
pub(crate) fn recalculate_workbook(artifact: &mut SpreadsheetArtifact) {
let updates = artifact
.sheets
.iter()
.enumerate()
.flat_map(|(sheet_index, sheet)| {
sheet.cells.iter().filter_map(move |(address, cell)| {
cell.formula
.as_ref()
.map(|formula| (sheet_index, *address, formula.clone()))
})
})
.map(|(sheet_index, address, formula)| {
let mut stack = BTreeSet::new();
let value = evaluate_formula(artifact, sheet_index, &formula, &mut stack)
.unwrap_or_else(|error| {
Some(SpreadsheetCellValue::Error(map_error_to_code(&error)))
});
(sheet_index, address, value)
})
.collect::<Vec<_>>();
for (sheet_index, address, value) in updates {
if let Some(sheet) = artifact.sheets.get_mut(sheet_index)
&& let Some(cell) = sheet.cells.get_mut(&address)
{
cell.value = value;
}
}
}
fn evaluate_formula(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
formula: &str,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<Option<SpreadsheetCellValue>, SpreadsheetArtifactError> {
let source = formula.trim().trim_start_matches('=');
let tokens = tokenize(source)?;
let mut parser = Parser::new(tokens);
let expr = parser.parse_expression()?;
if parser.has_remaining() {
return Err(SpreadsheetArtifactError::Formula {
location: formula.to_string(),
message: "unexpected trailing tokens".to_string(),
});
}
match evaluate_expr(artifact, sheet_index, &expr, stack)? {
EvalValue::Scalar(value) => Ok(value),
EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula {
location: formula.to_string(),
message: "range expressions are only allowed inside functions".to_string(),
}),
}
}
fn evaluate_expr(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
expr: &Expr,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<EvalValue, SpreadsheetArtifactError> {
match expr {
Expr::Number(value) => Ok(EvalValue::Scalar(Some(number_to_value(*value)))),
Expr::Cell(address) => evaluate_cell_reference(artifact, sheet_index, *address, stack),
Expr::Range(range) => {
let sheet = artifact.sheets.get(sheet_index).ok_or_else(|| {
SpreadsheetArtifactError::Formula {
location: range.to_a1(),
message: "sheet index was not found".to_string(),
}
})?;
let values = range
.addresses()
.map(|address| sheet.get_cell(address).and_then(|cell| cell.value.clone()))
.collect::<Vec<_>>();
Ok(EvalValue::Range(values))
}
Expr::UnaryMinus(inner) => {
let value = evaluate_scalar(artifact, sheet_index, inner, stack)?;
Ok(EvalValue::Scalar(match value {
None => Some(SpreadsheetCellValue::Integer(0)),
Some(SpreadsheetCellValue::Integer(value)) => {
Some(SpreadsheetCellValue::Integer(-value))
}
Some(SpreadsheetCellValue::Float(value)) => {
Some(SpreadsheetCellValue::Float(-value))
}
Some(SpreadsheetCellValue::Error(value)) => {
Some(SpreadsheetCellValue::Error(value))
}
Some(_) => Some(SpreadsheetCellValue::Error("#VALUE!".to_string())),
}))
}
Expr::Binary { op, left, right } => {
let left = evaluate_scalar(artifact, sheet_index, left, stack)?;
let right = evaluate_scalar(artifact, sheet_index, right, stack)?;
Ok(EvalValue::Scalar(Some(apply_binary_op(*op, left, right)?)))
}
Expr::Function { name, args } => {
let mut numeric = Vec::new();
for arg in args {
match evaluate_expr(artifact, sheet_index, arg, stack)? {
EvalValue::Scalar(value) => {
if let Some(number) = scalar_to_number(value.clone())? {
numeric.push(number);
}
}
EvalValue::Range(values) => {
for value in values {
if let Some(number) = scalar_to_number(value.clone())? {
numeric.push(number);
}
}
}
}
}
let upper = name.to_ascii_uppercase();
let result = match upper.as_str() {
"SUM" => numeric.iter().sum::<f64>(),
"AVERAGE" => {
if numeric.is_empty() {
return Ok(EvalValue::Scalar(None));
}
numeric.iter().sum::<f64>() / numeric.len() as f64
}
"MIN" => numeric.iter().copied().reduce(f64::min).unwrap_or(0.0),
"MAX" => numeric.iter().copied().reduce(f64::max).unwrap_or(0.0),
_ => {
return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error(
"#NAME?".to_string(),
))));
}
};
Ok(EvalValue::Scalar(Some(number_to_value(result))))
}
}
}
fn evaluate_scalar(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
expr: &Expr,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<Option<SpreadsheetCellValue>, SpreadsheetArtifactError> {
match evaluate_expr(artifact, sheet_index, expr, stack)? {
EvalValue::Scalar(value) => Ok(value),
EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula {
location: format!("{expr:?}"),
message: "expected a scalar expression".to_string(),
}),
}
}
fn evaluate_cell_reference(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
address: CellAddress,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<EvalValue, SpreadsheetArtifactError> {
let Some(sheet) = artifact.sheets.get(sheet_index) else {
return Err(SpreadsheetArtifactError::Formula {
location: address.to_a1(),
message: "sheet index was not found".to_string(),
});
};
let key = (sheet_index, address);
if !stack.insert(key) {
return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error(
"#CYCLE!".to_string(),
))));
}
let value = if let Some(cell) = sheet.get_cell(address) {
if let Some(formula) = &cell.formula {
evaluate_formula(artifact, sheet_index, formula, stack)?
} else {
cell.value.clone()
}
} else {
None
};
stack.remove(&key);
Ok(EvalValue::Scalar(value))
}
fn apply_binary_op(
op: BinaryOp,
left: Option<SpreadsheetCellValue>,
right: Option<SpreadsheetCellValue>,
) -> Result<SpreadsheetCellValue, SpreadsheetArtifactError> {
if let Some(SpreadsheetCellValue::Error(value)) = &left {
return Ok(SpreadsheetCellValue::Error(value.clone()));
}
if let Some(SpreadsheetCellValue::Error(value)) = &right {
return Ok(SpreadsheetCellValue::Error(value.clone()));
}
let left = scalar_to_number(left)?;
let right = scalar_to_number(right)?;
let left = left.unwrap_or(0.0);
let right = right.unwrap_or(0.0);
let result = match op {
BinaryOp::Add => left + right,
BinaryOp::Subtract => left - right,
BinaryOp::Multiply => left * right,
BinaryOp::Divide => {
if right == 0.0 {
return Ok(SpreadsheetCellValue::Error("#DIV/0!".to_string()));
}
left / right
}
};
Ok(number_to_value(result))
}
fn scalar_to_number(
value: Option<SpreadsheetCellValue>,
) -> Result<Option<f64>, SpreadsheetArtifactError> {
match value {
None => Ok(None),
Some(SpreadsheetCellValue::Integer(value)) => Ok(Some(value as f64)),
Some(SpreadsheetCellValue::Float(value)) => Ok(Some(value)),
Some(SpreadsheetCellValue::Bool(value)) => Ok(Some(if value { 1.0 } else { 0.0 })),
Some(SpreadsheetCellValue::Error(value)) => Err(SpreadsheetArtifactError::Formula {
location: value,
message: "encountered error value".to_string(),
}),
Some(other) => Err(SpreadsheetArtifactError::Formula {
location: format!("{other:?}"),
message: "value is not numeric".to_string(),
}),
}
}
fn number_to_value(number: f64) -> SpreadsheetCellValue {
if number.fract() == 0.0 {
SpreadsheetCellValue::Integer(number as i64)
} else {
SpreadsheetCellValue::Float(number)
}
}
fn map_error_to_code(error: &SpreadsheetArtifactError) -> String {
match error {
SpreadsheetArtifactError::Formula { message, .. } => {
if message.contains("cycle") {
"#CYCLE!".to_string()
} else if message.contains("not numeric") || message.contains("scalar") {
"#VALUE!".to_string()
} else {
"#ERROR!".to_string()
}
}
SpreadsheetArtifactError::InvalidAddress { .. } => "#REF!".to_string(),
_ => "#ERROR!".to_string(),
}
}
fn tokenize(source: &str) -> Result<Vec<Token>, SpreadsheetArtifactError> {
let chars = source.chars().collect::<Vec<_>>();
let mut index = 0usize;
let mut tokens = Vec::new();
while index < chars.len() {
let ch = chars[index];
if ch.is_ascii_whitespace() {
index += 1;
continue;
}
match ch {
'+' => {
tokens.push(Token::Plus);
index += 1;
}
'-' => {
tokens.push(Token::Minus);
index += 1;
}
'*' => {
tokens.push(Token::Star);
index += 1;
}
'/' => {
tokens.push(Token::Slash);
index += 1;
}
'(' => {
tokens.push(Token::LParen);
index += 1;
}
')' => {
tokens.push(Token::RParen);
index += 1;
}
':' => {
tokens.push(Token::Colon);
index += 1;
}
',' => {
tokens.push(Token::Comma);
index += 1;
}
'0'..='9' | '.' => {
let start = index;
index += 1;
while index < chars.len() && (chars[index].is_ascii_digit() || chars[index] == '.')
{
index += 1;
}
let number = source[start..index].parse::<f64>().map_err(|_| {
SpreadsheetArtifactError::Formula {
location: source.to_string(),
message: "invalid numeric literal".to_string(),
}
})?;
tokens.push(Token::Number(number));
}
'A'..='Z' | 'a'..='z' | '_' => {
let start = index;
index += 1;
while index < chars.len()
&& (chars[index].is_ascii_alphanumeric() || chars[index] == '_')
{
index += 1;
}
let text = source[start..index].to_string();
if text.chars().any(|part| part.is_ascii_digit())
&& text.chars().any(|part| part.is_ascii_alphabetic())
{
tokens.push(Token::Cell(text));
} else {
tokens.push(Token::Ident(text));
}
}
other => {
return Err(SpreadsheetArtifactError::Formula {
location: source.to_string(),
message: format!("unsupported token `{other}`"),
});
}
}
}
Ok(tokens)
}
struct Parser {
tokens: Vec<Token>,
index: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, index: 0 }
}
fn has_remaining(&self) -> bool {
self.index < self.tokens.len()
}
fn parse_expression(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
let mut expr = self.parse_term()?;
while let Some(token) = self.peek() {
let op = match token {
Token::Plus => BinaryOp::Add,
Token::Minus => BinaryOp::Subtract,
_ => break,
};
self.index += 1;
let right = self.parse_term()?;
expr = Expr::Binary {
op,
left: Box::new(expr),
right: Box::new(right),
};
}
Ok(expr)
}
fn parse_term(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
let mut expr = self.parse_factor()?;
while let Some(token) = self.peek() {
let op = match token {
Token::Star => BinaryOp::Multiply,
Token::Slash => BinaryOp::Divide,
_ => break,
};
self.index += 1;
let right = self.parse_factor()?;
expr = Expr::Binary {
op,
left: Box::new(expr),
right: Box::new(right),
};
}
Ok(expr)
}
fn parse_factor(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
match self.peek() {
Some(Token::Minus) => {
self.index += 1;
Ok(Expr::UnaryMinus(Box::new(self.parse_factor()?)))
}
_ => self.parse_primary(),
}
}
fn parse_primary(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
match self.next().cloned() {
Some(Token::Number(value)) => Ok(Expr::Number(value)),
Some(Token::Cell(address)) => {
let start = CellAddress::parse(&address)?;
if matches!(self.peek(), Some(Token::Colon)) {
self.index += 1;
let Some(Token::Cell(end)) = self.next().cloned() else {
return Err(SpreadsheetArtifactError::Formula {
location: address,
message: "expected cell after `:`".to_string(),
});
};
Ok(Expr::Range(CellRange::from_start_end(
start,
CellAddress::parse(&end)?,
)))
} else {
Ok(Expr::Cell(start))
}
}
Some(Token::Ident(name)) => {
if !matches!(self.next(), Some(Token::LParen)) {
return Err(SpreadsheetArtifactError::Formula {
location: name,
message: "expected `(` after function name".to_string(),
});
}
let mut args = Vec::new();
if !matches!(self.peek(), Some(Token::RParen)) {
loop {
args.push(self.parse_expression()?);
if matches!(self.peek(), Some(Token::Comma)) {
self.index += 1;
continue;
}
break;
}
}
if !matches!(self.next(), Some(Token::RParen)) {
return Err(SpreadsheetArtifactError::Formula {
location: name,
message: "expected `)`".to_string(),
});
}
Ok(Expr::Function { name, args })
}
Some(Token::LParen) => {
let expr = self.parse_expression()?;
if !matches!(self.next(), Some(Token::RParen)) {
return Err(SpreadsheetArtifactError::Formula {
location: format!("{expr:?}"),
message: "expected `)`".to_string(),
});
}
Ok(expr)
}
other => Err(SpreadsheetArtifactError::Formula {
location: format!("{other:?}"),
message: "unexpected token".to_string(),
}),
}
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.index)
}
fn next(&mut self) -> Option<&Token> {
let token = self.tokens.get(self.index);
self.index += usize::from(token.is_some());
token
}
}

Some files were not shown because too many files have changed in this diff Show More