Compare commits

...

73 Commits

Author SHA1 Message Date
Omer Strulovich
17484b3f9e Add terminal title spinner while running
Co-authored-by: Codex <noreply@openai.com>
2026-03-05 14:40:04 -05:00
Omer Strulovich
1d4c6ca84e Add /title terminal title override
Co-authored-by: Codex <noreply@openai.com>
2026-03-05 14:39:37 -05:00
jif-oai
8159f05dfd feat: wire spreadsheet artifact (#13362) 2026-03-03 15:27:37 +00:00
jif-oai
24ba01b9da feat: artifact presentation part 7 (#13360) 2026-03-03 15:03:25 +00:00
jif-oai
1df040e62b feat: add multi-actions to presentation tool (#13357) 2026-03-03 14:37:26 +00:00
jif-oai
ad393fa753 feat: pres artifact part 5 (#13355)
Mostly written by Codex
2026-03-03 14:08:01 +00:00
jif-oai
821024f9c9 feat: spreadsheet part 3 (#13350)
=
2026-03-03 13:09:37 +00:00
jif-oai
a7d90b867d feat: presentation part 4 (#13348) 2026-03-03 12:51:31 +00:00
jif-oai
875eaac0d1 feat: spreadsheet v2 (#13347) 2026-03-03 12:38:27 +00:00
jif-oai
8c5e50ef39 feat: spreadsheet artifact (#13345) 2026-03-03 12:25:40 +00:00
jif-oai
564a883c2a feat: pres artifact 3 (#13346) 2026-03-03 12:18:25 +00:00
jif-oai
72dc444b2c feat: pres artifact 2 (#13344) 2026-03-03 12:00:34 +00:00
jif-oai
4874b9291a feat: presentation artifact p1 (#13341)
Part 1 of presentation tool artifact
2026-03-03 11:38:03 +00:00
pash-openai
07e532dcb9 app-server service tier plumbing (plus some cleanup) (#13334)
followup to https://github.com/openai/codex/pull/13212 to expose fast
tier controls to app server
(majority of this PR is generated schema jsons - actual code is +69 /
-35 and +24 tests )

- add service tier fields to the app-server protocol surfaces used by
thread lifecycle, turn start, config, and session configured events
- thread service tier through the app-server message processor and core
thread config snapshots
- allow runtime config overrides to carry service tier for app-server
callers

cleanup:
- Removing useless "legacy" code supporting "standard" - we moved to
None | "fast", so "standard" is not needed.
2026-03-03 02:35:09 -08:00
jif-oai
938c6dd388 fix: db windows path (#13336) 2026-03-03 09:50:52 +00:00
jif-oai
cacefb5228 fix: agent when profile (#13235)
Co-authored-by: Josh McKinney <joshka@openai.com>
Co-authored-by: Codex <noreply@openai.com>
2026-03-03 09:20:25 +00:00
jif-oai
3166a5ba82 fix: agent race (#13248)
https://github.com/openai/codex/issues/13244
2026-03-03 09:19:37 +00:00
bwanner-oai
6deb72c04b Renaming Team to Business plan during TUI onboarding (#13313)
Team is referred to as "Business"
2026-03-02 23:13:29 -08:00
Felipe Coury
745c48b088 fix(core): scope file search gitignore to repository context (#13250)
Closes #3493

## Problem

When a user's home directory (or any ancestor) contains a broad
`.gitignore` (e.g. `*` + `!.gitignore`), the `@` file mention picker in
Codex silently hides valid repository files like `package.json`. The
picker returns `no matches` for searches that should succeed. This is
surprising because manually typed paths still work, making the failure
hard to diagnose.

## Mental model

Git itself never walks above the repository root to assemble its ignore
list. Its `.gitignore` resolution is strictly scoped: it reads
`.gitignore` files from the repo root downward, the per-repo
`.git/info/exclude`, and the user's global excludes file (via
`core.excludesFile`). A `.gitignore` sitting in a parent directory above
the repo root has no effect on `git status`, `git ls-files`, or any
other git operation. Our file search should replicate this contract
exactly.

The `ignore` crate's `WalkBuilder` has a `require_git` flag that
controls whether it follows this contract:

- `require_git(false)` (the previous setting): the walker reads
`.gitignore` files from _all_ ancestor directories, even those above or
outside the repository root. This is a deliberate divergence from git's
behavior in the `ignore` crate, intended for non-git use cases. It means
a `~/.gitignore` with `*` will suppress every file in the walk—something
git itself would never do.

- `require_git(true)` (this fix): the walker only applies `.gitignore`
semantics when it detects a `.git` directory, scoping ignore resolution
to the repository boundary. This matches git's own behavior: parent
`.gitignore` files above the repo root have no effect.

The fix is a one-line change: `require_git(false)` becomes
`require_git(true)`.

## How `require_git(false)` got here

The setting was introduced in af338cc (#2981, "Improve @ file search:
include specific hidden dirs such as .github, .gitlab"). That PR's goal
was to make hidden directories like `.github` and `.vscode` discoverable
by setting `.hidden(false)` on the walker. The `require_git(false)` was
added alongside it with the comment _"Don't require git to be present to
apply git-related ignore rules"_—the author likely intended gitignore
rules to still filter results even when no `.git` directory exists (e.g.
searching an extracted tarball that has a `.gitignore` but no `.git`).

The unintended consequence: with `require_git(false)`, the `ignore`
crate walks _above_ the search root to find `.gitignore` files in
ancestor directories. This is a side effect the original author almost
certainly didn't anticipate. The PR message says "Preserve `.gitignore`
semantics," but `require_git(false)` actually _breaks_ git's semantics
by applying ancestor ignore files that git itself would never read.

In short: the intent was "apply gitignore even without `.git`" but the
effect was "apply gitignore from every ancestor directory." This fix
restores git-correct scoping.

## Non-goals

- This PR does not change behavior when `respect_gitignore` is `false`
(that path already disables all git-related ignore rules).
- The first test
(`parent_gitignore_outside_repo_does_not_hide_repo_files`) intentionally
omits `git init`. The `ignore` crate's `require_git(true)` causes it to
skip gitignore processing entirely when no `.git` exists, which is the
desired behavior for that scenario. A second test
(`git_repo_still_respects_local_gitignore_when_enabled`) covers the
complementary case with a real git repo.

## Tradeoffs

**Behavioral shift**: With `require_git(true)`, directories that contain
`.gitignore` files but are _not_ inside a git repository will no longer
have those ignore rules applied during `@` search. This is a correctness
improvement for the primary use case (searching inside repos), but
changes behavior for the edge case of searching non-repo directories
that happen to have `.gitignore` files. In practice, Codex is
overwhelmingly used inside git repositories, so this tradeoff strongly
favors the fix.

**Two test strategies**: The first test omits `git init` to verify
parent ignore leakage is blocked; the second runs `git init` to verify
the repo's own `.gitignore` is still honored. Together they cover both
sides of the `require_git(true)` contract.

## Architecture

The change is in `walker_worker()` within
`codex-rs/file-search/src/lib.rs`, which configures the
`ignore::WalkBuilder` used by the file search walker thread. The walker
feeds discovered file paths into `nucleo` for fuzzy matching. The
`require_git` flag controls whether the walker consults `.gitignore`
files at all—it sits upstream of all ignore processing.

```
walker_worker
  └─ WalkBuilder::new(root)
       ├─ .hidden(false)         — include dotfiles
       ├─ .follow_links(true)    — follow symlinks
       ├─ .require_git(true)     — ← THE FIX: only apply gitignore in git repos
       └─ (conditional) git_ignore(false), git_global(false), etc.
            └─ applied when respect_gitignore == false
```

## Tests

- `parent_gitignore_outside_repo_does_not_hide_repo_files`: creates a
temp directory tree with a parent `.gitignore` containing `*`, a child
"repo" directory with `package.json` and `.vscode/settings.json`, and
asserts that both files are discoverable via `run()` with
`respect_gitignore: true`.
- `git_repo_still_respects_local_gitignore_when_enabled`: the
complementary test—runs `git init` inside the child directory and
verifies that the repo's own `.gitignore` exclusions still work (e.g.
`.vscode/extensions.json` is excluded while `.vscode/settings.json` is
whitelisted). Confirms that `require_git(true)` does not disable
gitignore processing inside actual git repositories.
2026-03-02 21:52:20 -07:00
pash-openai
2f5b01abd6 add fast mode toggle (#13212)
- add a local Fast mode setting in codex-core (similar to how model id
is currently stored on disk locally)
- send `service_tier=priority` on requests when Fast is enabled
- add `/fast` in the TUI and persist it locally
- feature flag
2026-03-02 20:29:33 -08:00
rakan-oai
56cc2c71f4 tui: preserve kill buffer across submit and slash-command clears (#12006)
## Problem

Before this change, composer paths that cleared the textarea after
submit or slash-command dispatch
also cleared the textarea kill buffer. That meant a user could `Ctrl+K`
part of a draft, trigger a
composer action that cleared the visible draft, and then lose the
ability to `Ctrl+Y` the killed
text back.

This was especially awkward for workflows where the user wants to
temporarily remove text, run a
composer action such as changing reasoning level or dispatching a slash
command, and then restore
the killed text into the now-empty draft.

## Mental model

This change separates visible draft state from editing-history state.

The visible draft includes the current textarea contents and text
elements that should be cleared
when the composer submits or dispatches a command. The kill buffer is
different: it represents the
most recent killed text and should survive those composer-driven clears
so the user can still yank
it back afterward.

After this change, submit and slash-command dispatch still clear the
visible textarea contents, but
they no longer erase the most recent kill.

## Non-goals

This does not implement a multi-entry kill ring or change the semantics
of `Ctrl+K` and `Ctrl+Y`
beyond preserving the existing yank target across these clears.

It also does not change how submit, slash-command parsing, prompt
expansion, or attachment handling
work, except that those flows no longer discard the textarea kill buffer
as a side effect of
clearing the draft.

## Tradeoffs

The main tradeoff is that clearing the visible textarea is no longer
equivalent to fully resetting
all editing state. That is intentional here, because submit and
slash-command dispatch are composer
actions, not requests to forget the user's most recent kill.

The benefit is better editing continuity. The cost is that callers must
understand that full-buffer
replacement resets visible draft state but not the kill buffer.

## Architecture

The behavioral change is in `TextArea`: full-buffer replacement now
rebuilds text and elements
without clearing `kill_buffer`.

`ChatComposer` already clears the textarea after successful submit and
slash-command dispatch by
calling into those textarea replacement paths. With this change, those
existing composer flows
inherit the new behavior automatically: the visible draft is cleared,
but the last killed text
remains available for `Ctrl+Y`.

The tests cover both layers:

- `TextArea` verifies that the kill buffer survives full-buffer
replacement.
- `ChatComposer` verifies that it survives submit.
- `ChatComposer` also verifies that it survives slash-command dispatch.

## Observability

There is no dedicated logging for kill-buffer preservation. The most
direct way to reason about the
behavior is to inspect textarea-wide replacement paths and confirm
whether they treat the kill
buffer as visible-buffer state or as editing-history state.

If this regresses in the future, the likely failure mode is simple and
user-visible: `Ctrl+Y` stops
restoring text after submit or slash-command clears even though ordinary
kill/yank still works
within a single uninterrupted draft.

## Tests

Added focused regression coverage for the new contract:

- `kill_buffer_persists_across_set_text`
- `kill_buffer_persists_after_submit`
- `kill_buffer_persists_after_slash_command_dispatch`

Local verification:
- `just fmt`
- `cargo test -p codex-tui`

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
2026-03-03 02:06:08 +00:00
Celia Chen
0bb152b01d chore: remove SkillMetadata.permissions and derive skill sandboxing from permission_profile (#13061)
## Summary

This change removes the compiled permissions field from skill metadata
and keeps permission_profile as the single source of truth.

Skill loading no longer compiles skill permissions eagerly. Instead, the
zsh-fork skill escalation path compiles `skill.permission_profile` when
it needs to determine the sandbox to apply for a skill script.

  ## Behavior change

  For skills that declare:
```
  permissions: {}
```
we now treat that the same as having no skill permissions override,
instead of creating and using a default readonly sandbox. This change
makes the behavior more intuitive:

  - only non-empty skill permission profiles affect sandboxing
- omitting permissions and writing permissions: {} now mean the same
thing
- skill metadata keeps a single permissions representation instead of
storing derived state too

Overall, this makes skill sandbox behavior easier to understand and more
predictable.
2026-03-03 01:29:53 +00:00
Owen Lin
9965bf31fa feat(app-server-test-client): support tracing (#13286) 2026-03-02 17:24:48 -08:00
Brian Fioca
50084339a6 Adjusting plan prompt for clarity and verbosity (#13284)
`plan.md` prompt changes to tighten plan clarity and verbosity.
2026-03-03 01:14:39 +00:00
Ruslan Nigmatullin
9022cdc563 app-server: Silence thread status changes caused by thread being created (#13079)
Currently we emit `thread/status/changed` with `Idle` status right
before sending `thread/started` event (which also has `Idle` status in
it).
It feels that there is no point in that as client has no way to know
prior state of the thread as it didn't exist yet, so silence these kinds
of notifications.
2026-03-03 00:52:28 +00:00
Owen Lin
146b798129 fix(app-server): emit turn/started only when turn actually starts (#13261)
This is a follow-up for https://github.com/openai/codex/pull/13047

## Why
We had a race where `turn/started` could be observed before the thread
had actually transitioned to `Active`. This was because we eagerly
emitted `turn/started` in the request handler for `turn/start` (and
`review/start`).

That was showing up as flaky `thread/resume` tests, but the real issue
was broader: a client could see `turn/started` and still get back an
idle thread immediately afterward.

The first idea was to eagerly call
`thread_watch_manager.note_turn_started(...)` from the `turn/start`
request path. That turns out to be unsafe, because
`submit(Op::UserInput)` only queues work. If a turn starts and completes
quickly, request-path bookkeeping can race with the real lifecycle
events and leave stale running state behind.

**The real fix** is to move `turn/started` to emit only after the turn
_actually_ starts, so we do that by waiting for the
`EventMsg::TurnStarted` notification emitted by codex core. We do this
for both `turn/start` and `review/start`.

I also verified this change is safe for our first-party codex apps -
they don't have any assumptions that `turn/started` is emitted before
the RPC response to `turn/start` (which is correct anyway).

I also removed `single_client_mode` since it isn't really necessary now.

## Testing
- `cargo test -p codex-app-server thread_resume -- --nocapture`
- `cargo test -p codex-app-server
'suite::v2::turn_start::turn_start_emits_notifications_and_accepts_model_override'
-- --exact --nocapture`
- `cargo test -p codex-app-server`
2026-03-02 16:43:31 -08:00
Ahmed Ibrahim
b20b6aa46f Update realtime websocket API (#13265)
- migrate the realtime websocket transport to the new session and
handoff flow
- make the realtime model configurable in config.toml and use API-key
auth for the websocket

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 16:05:40 -08:00
Owen Lin
d473e8d56d feat(app-server): add tracing to all app-server APIs (#13285)
### Overview
This PR adds the first piece of tracing for app-server JSON-RPC
requests.

There are two main changes:
- JSON-RPC requests can now take an optional W3C trace context at the
top level via a `trace` field (`traceparent` / `tracestate`).
- app-server now creates a dedicated request span for every inbound
JSON-RPC request in `MessageProcessor`, and uses the request-level trace
context as the parent when present.

For compatibility with existing flows, app-server still falls back to
the TRACEPARENT env var when there is no request-level traceparent.

This PR is intentionally scoped to the app-server boundary. In a
followup, we'll actually propagate trace context through the async
handoff into core execution spans like run_turn, which will make
app-server traces much more useful.

### Spans
A few details on the app-server span shape:
- each inbound request gets its own server span
- span/resource names are based on the JSON-RPC method (`initialize`,
`thread/start`, `turn/start`, etc.)
- spans record transport (stdio vs websocket), request id, connection
id, and client name/version when available
- `initialize` stores client metadata in session state so later requests
on the same connection can reuse it
2026-03-02 16:01:41 -08:00
Ruslan Nigmatullin
14fcb6645c app-server: Update thread/name/set to support not-loaded threads (#13282)
Currently `thread/name/set` does only work for loaded threads.
Expand the scope to also support persisted but not-yet-loaded ones for a
more predictable API surface.
This will make it possible to rename threads discovered via
`thread/list` and similar operations.
2026-03-02 15:13:18 -08:00
Josh McKinney
75e7c804ea test(app-server): increase flow test timeout to reduce flake (#11814)
## Summary
- increase `DEFAULT_READ_TIMEOUT` in `codex_message_processor_flow` from
20s to 45s
- keep test behavior the same while avoiding platform timing flakes

## Why
Windows ARM64 CI showed these tests taking about 24s before
`task_complete`, which could fail early and produce wiremock
request-count mismatches.

## Testing
- just fmt
- cargo test -p codex-app-server codex_message_processor_flow --
--nocapture
2026-03-02 12:29:28 -08:00
Dylan Hurd
e10df4ba10 fix(core) shell_snapshot multiline exports (#12642)
## Summary
Codex discovered this one - shell_snapshot tests were breaking on my
machine because I had a multiline env var. We should handle these!

## Testing
- [x] existing tests pass
- [x] Updated unit tests
2026-03-02 12:08:17 -07:00
jif-oai
f8838fd6f3 feat: enable ma through /agent (#13246)
<img width="639" height="139" alt="Screenshot 2026-03-02 at 16 06 41"
src="https://github.com/user-attachments/assets/c006fcec-c1e7-41ce-bb84-c121d5ffb501"
/>

Then
<img width="372" height="37" alt="Screenshot 2026-03-02 at 16 06 49"
src="https://github.com/user-attachments/assets/aa4ad703-e7e7-4620-9032-f5cd4f48ff79"
/>
2026-03-02 18:37:29 +00:00
Charley Cunningham
7979ce453a tui: restore draft footer hints (#13202)
## Summary
- restore `Tab to queue` when a draft is present and the agent is
running
- keep draft-idle footers passive by showing the normal footer or status
line instead of `? for shortcuts`
- align footer snapshot coverage with the updated draft footer behavior

## Codex author
`codex resume 019c7f1c-43aa-73e0-97c7-40f457396bb0`

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 10:26:13 -08:00
Eric Traut
7709bf32a3 Fix project trust config parsing so CLI overrides work (#13090)
Fixes #13076

This PR fixes a bug that causes command-line config overrides for MCP
subtables to not be merged correctly.

Summary
- make project trust loading go through the dedicated struct so CLI
overrides can update trusted project-local MCP transports

---------

Co-authored-by: jif-oai <jif@openai.com>
2026-03-02 11:10:38 -07:00
Michael Bolin
3241c1c6cc fix: use https://git.savannah.gnu.org/git/bash instead of https://github.com/bolinfest/bash (#13057)
Historically, we cloned the Bash repo from
https://github.com/bminor/bash, but for whatever reason, it was removed
at some point.

I had a local clone of it, so I pushed it to
https://github.com/bolinfest/bash so that we could continue running our
CI job. I did this in https://github.com/openai/codex/pull/9563, and as
you can see, I did not tamper with the commit hash we used as the basis
of this build.

Using a personal fork is not great, so this PR changes the CI job to use
what appears to be considered the source of truth for Bash, which is
https://git.savannah.gnu.org/git/bash.git.

Though in testing this out, it appears this Git server does not support
the combination of `git clone --depth 1
https://git.savannah.gnu.org/git/bash` and `git fetch --depth 1 origin
a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b`, as it fails with the
following error:

```
error: Server does not allow request for unadvertised object a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
```

so unfortunately this means that we have to do a full clone instead of a
shallow clone in our CI jobs, which will be a bit slower.

Also updated `codex-rs/shell-escalation/README.md` to reflect this
change.
2026-03-02 09:09:54 -08:00
jif-oai
9a42a56d8f chore: /multiagent alias for /agent (#13249)
Add a `/mutli-agents` alias for `/agent` and update the wording
2026-03-02 16:51:54 +00:00
daveaitel-openai
c2e126f92a core: reuse parent shell snapshot for thread-spawn subagents (#13052)
## Summary
- reuse the parent shell snapshot when spawning/forking/resuming
`SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })` sessions
- plumb inherited snapshot through `AgentControl -> ThreadManager ->
Codex::spawn -> SessionConfiguration`
- skip shell snapshot refresh on cwd updates for thread-spawn subagents
so inherited snapshots are not replaced

## Why
- avoids per-subagent shell snapshot creation and cleanup work
- keeps thread-spawn subagents on the parent snapshot path, matching the
intended parent/child snapshot model

## Validation
- `just fmt` (in `codex-rs`)
- `cargo test -p codex-core --no-run`
- `cargo test -p codex-core spawn_agent -- --nocapture`
- `cargo test -p codex-core --test all
suite::agent_jobs::spawn_agents_on_csv_runs_and_exports`

## Notes
- full `cargo test -p codex-core --test all` was left running separately
for broader verification

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 15:53:15 +00:00
jif-oai
2a5bcc053f fix: esc in /agent (#13131)
Fix https://github.com/openai/codex/issues/13093
2026-03-02 15:49:06 +00:00
jif-oai
1905597017 feat: update memories config names (#13237) 2026-03-02 15:25:39 +00:00
jif-oai
b649953845 feat: polluted memories (#13008)
Add a feature flag to disable memory creation for "polluted"
2026-03-02 11:57:32 +00:00
jif-oai
b08bdd91e3 fix: /status when sub-agent (#13130)
Fix https://github.com/openai/codex/issues/13066
2026-03-02 11:57:15 +00:00
gabec-openai
9685e7d6d1 Improve subagent contrast in TUI (#13197)
## Summary
- raise contrast for subagent transcript labels and fallback states
- remove low-contrast dim styling from role tags and error details
- make the closed-agent picker dot readable in dark theme

## Validation
- just fmt
- just fix -p codex-tui
- cargo test -p codex-tui

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 12:16:49 +01:00
Eric Traut
d94f0b6ce7 Fix issue deduplication workflow for Codex issues (#13215)
Fixes #13203

Summary
- split the duplicate-finding workflow into two jobs so we gather all
issues first
- add an open-issue fallback job that runs only when the full scan finds
nothing
- centralize final selection so `comment-on-issue` always sees the best
dedupe output
2026-03-01 22:45:50 -07:00
Ahmed Ibrahim
0aeb55bf08 Record realtime close marker on replacement (#13058)
## Summary
- record a realtime close developer message when a new realtime session
replaces an active one
- assert the replacement marker through the mocked responses request
path

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Charles Cunningham <ccunningham@openai.com>
2026-03-01 13:54:12 -08:00
Thibault Sottiaux
c9cef6ba9e [codex] include plan type in account updates (#13181)
This change fixes a Codex app account-state sync bug where clients could
know the user was signed in but still miss the ChatGPT subscription
tier, which could lead to incorrect upgrade messaging for paid users.

The root cause was that `account/updated` only carried `authMode` while
plan information was available separately via `account/read` and
rate-limit snapshots, so this update adds `planType` to
`account/updated`, populates it consistently across login and refresh
paths.
2026-03-01 13:43:37 -08:00
Leo Shimonaka
4ae60cf03c fix: MacOSAutomationPermission::BundleIDs should allow communicating … (#12989)
…with launchservicesd

Add mach lookup for `launchservicesd` when extending the sandbox for
`MacOSAutomationPermission::BundleIDs`. This is necessary so that the
target application can be launched for automation.

This omission was due to a spec error in a document, which has been
fixed.
2026-03-01 11:00:54 -08:00
xl-openai
752402c4fe feat: load from plugins (#12864)
Support loading plugins.

Plugins can now be enabled via [plugins.<name>] in config.toml. They are
loaded as first-class entities through PluginsManager, and their default
skills/ and .mcp.json contributions are integrated into the existing
skills and MCP flows.
2026-03-01 10:50:56 -08:00
Michael Bolin
6a673e7339 core: resolve host_executable() rules during preflight (#13065)
## Why

[#12964](https://github.com/openai/codex/pull/12964) added
`host_executable()` support to `codex-execpolicy`, and
[#13046](https://github.com/openai/codex/pull/13046) adopted it in the
zsh-fork interception path.

The remaining gap was the preflight execpolicy check in
`core/src/exec_policy.rs`. That path derives approval requirements
before execution for `shell`, `shell_command`, and `unified_exec`, but
it was still using the default exact-token matcher.

As a result, a command that already included an absolute executable
path, such as `/usr/bin/git status`, could still miss a basename rule
like `prefix_rule(pattern = ["git"], ...)` during preflight even when
the policy also defined a matching `host_executable(name = "git", ...)`
entry.

This PR brings the same opt-in `host_executable()` resolution to the
preflight approval path when an absolute program path is already present
in the parsed command.

## What Changed

- updated
`ExecPolicyManager::create_exec_approval_requirement_for_command()` in
`core/src/exec_policy.rs` to use `check_multiple_with_options(...)` with
`MatchOptions { resolve_host_executables: true }`
- kept the existing shell parsing flow for approval derivation, but now
allow basename rules to match absolute executable paths during preflight
when `host_executable()` permits it
- updated requested-prefix amendment evaluation to use the same
host-executable-aware matching mode, so suggested `prefix_rule()`
amendments are checked consistently for absolute-path commands
- added preflight coverage for:
- absolute-path commands that should match basename rules through
`host_executable()`
- absolute-path commands whose paths are not in the allowed
`host_executable()` mapping
  - requested prefix-rule amendments for absolute-path commands

## Verification

- `just fix -p codex-core`
- `cargo test -p codex-core --lib exec_policy::tests::`
2026-02-28 17:25:30 +00:00
jif-oai
74e5150b1e fix: package models.json for Bazel tests (#13129) 2026-02-28 17:21:02 +01:00
jif-oai
84b662e74f nit: disable on windows (#13127) 2026-02-28 14:55:16 +01:00
daveaitel-openai
eec3b1e235 Speed up subagent startup (#12935)
## Summary
- skip online model refresh for subagent sessions
- avoid rollout flushes during subagent startup
- keep /models refresh for non-subagent sessions

## Testing
- cargo test -p codex-core --test all
suite::models_etag_responses::refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
- cargo test -p codex-core --test all
suite::remote_models::remote_models_long_model_slug_is_sent_with_high_reasoning
- cargo test -p codex-core --test all
suite::model_switching::model_switch_to_smaller_model_updates_token_context_window
- cargo test -p codex-core --test all
suite::compact::pre_sampling_compact_runs_on_switch_to_smaller_context_model
- cargo test -p codex-core --test all
suite::compact::pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model
- cargo test -p codex-core --test all
suite::personality::remote_model_friendly_personality_instructions_with_feature

---------

Co-authored-by: Codex <noreply@openai.com>
2026-02-28 14:54:08 +01:00
jif-oai
3bfee6fcb5 nit: ignore `resume_startup_does_not_consume_model_availability_nux_c… (#13128) 2026-02-28 14:50:41 +01:00
Andi Liu
5f7c38baa9 Tune memory read-path for stale facts (#13088)
## Why
- tighten Codex memory-read behavior around stale facts and conflicting
memory
- encode the risk-of-drift vs verification-effort decision rule directly
in the read-path prompt
- make partial stale-detail updates explicit so correcting only the
answer is not treated as sufficient

## What changed
- update `codex-rs/core/templates/memories/read_path.md`
- add guidance for when to verify cheap local facts vs when to answer
from older memory with visible provenance
- strengthen same-turn `MEMORY.md` updates when stored concrete details
are stale

## Notes
- this is based on some staleness eval work
2026-02-28 14:48:47 +01:00
jif-oai
bee93ca2f3 chore: change mem default (#13125) 2026-02-28 14:45:27 +01:00
jif-oai
d33f4b54ac feat: skill disable respect config layer (#13027) 2026-02-28 14:17:05 +01:00
jif-oai
2b38b4e03b feat: approval for sub-agent in the TUI (#12995)
<img width="766" height="290" alt="Screenshot 2026-02-27 at 10 50 48"
src="https://github.com/user-attachments/assets/3bc96cd9-ed2c-4d67-a317-8f7b60abbbb1"
/>
2026-02-28 14:07:07 +01:00
Eric Traut
83177ed7a8 Enable analytics in codex exec and codex mcp-server (#13083)
Addresses #12913

`codex exec` was not correctly defaulting to Otel metrics to enabled 
`codex mcp-server` completely lacked an Otel collector

Summary:
- default to enabling analytics when `codex exec` initializes
OpenTelemetry so the CLI actually reports metrics again
- add a regression test that proves the flag remains enabled by default
- added Otel collector to `codex mcp-server`
2026-02-27 19:22:54 -07:00
alexsong-oai
e2fef7a3d2 Make cloud_requirements fail close (#13063)
Make it fail-close only for CLI for now
Will extend this for app-server later
2026-02-27 18:22:05 -08:00
Eric Traut
e6032eb0b7 Fix CLI feedback link (#13086)
Addresses #12967

About a month ago, I updated the Github bug report templates to
accommodate the (at the time) new Codex app. The `/feedback` code path
in the CLI was referencing one of the old templates, and I didn't
realize it at the time. This PR updates the link so users don't get an
empty bug template when using `/feedback`.
2026-02-27 19:02:40 -07:00
sayan-oai
033ef9cb9d feat: add debug clear-memories command to hard-wipe memories state (#13085)
#### what
adds a `codex debug clear-memories` command to help with clearing all
memories state from disk, sqlite db, and marking threads as
`memory_mode=disabled` so they don't get resummarized when the
`memories` feature is re-enabled.

#### tests
add tests
2026-02-27 17:45:55 -08:00
Ruslan Nigmatullin
8c1e3f3e64 app-server: Add ephemeral field to Thread object (#13084)
Currently there is no alternative way to know that thread is ephemeral,
only client which did create it has the knowledge.
2026-02-27 17:42:25 -08:00
Michael Bolin
1a8d930267 core: adopt host_executable() rules in zsh-fork (#13046)
## Why

[#12964](https://github.com/openai/codex/pull/12964) added
`host_executable()` support to `codex-execpolicy`, but the zsh-fork
interception path in `unix_escalation.rs` was still evaluating commands
with the default exact-token matcher.

That meant an intercepted absolute executable such as `/usr/bin/git
status` could still miss basename rules like `prefix_rule(pattern =
["git", "status"])`, even when the policy also defined a matching
`host_executable(name = "git", ...)` entry.

This PR adopts the new matching behavior in the zsh-fork runtime only.
That keeps the rollout intentionally narrow: zsh-fork already requires
explicit user opt-in, so it is a safer first caller to exercise the new
`host_executable()` scheme before expanding it to other execpolicy call
sites.

It also brings zsh-fork back in line with the current `prefix_rule()`
execution model. Until prefix rules can carry their own permission
profiles, a matched `prefix_rule()` is expected to rerun the intercepted
command unsandboxed on `allow`, or after the user accepts `prompt`,
instead of merely continuing inside the inherited shell sandbox.

## What Changed

- added `evaluate_intercepted_exec_policy()` in
`core/src/tools/runtimes/shell/unix_escalation.rs` to centralize
execpolicy evaluation for intercepted commands
- switched intercepted direct execs in the zsh-fork path to
`check_multiple_with_options(...)` with `MatchOptions {
resolve_host_executables: true }`
- added `commands_for_intercepted_exec_policy()` so zsh-fork policy
evaluation works from intercepted `(program, argv)` data instead of
reconstructing a synthetic command before matching
- left shell-wrapper parsing intentionally disabled by default behind
`ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING`, so
path-sensitive matching relies on later direct exec interception rather
than shell-script parsing
- made matched `prefix_rule()` decisions rerun intercepted commands with
`EscalationExecution::Unsandboxed`, while unmatched-command fallback
keeps the existing sandbox-preserving behavior
- extracted the zsh-fork test harness into
`core/tests/common/zsh_fork.rs` so both the skill-focused and
approval-focused integration suites can exercise the same runtime setup
- limited this change to the intercepted zsh-fork path rather than
changing every execpolicy caller at once
- added runtime coverage in
`core/src/tools/runtimes/shell/unix_escalation_tests.rs` for allowed and
disallowed `host_executable()` mappings and the wrapper-parsing modes
- added integration coverage in `core/tests/suite/approvals.rs` to
verify a saved `prefix_rule(pattern=["touch"], decision="allow")` reruns
under zsh-fork outside a restrictive `WorkspaceWrite` sandbox

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13046).
* #13065
* __->__ #13046
2026-02-28 01:41:23 +00:00
Owen Lin
8fa792868c fix(app-server): make thread/start non-blocking (#13033)
Stop `thread/start` from blocking other app-server requests.

Before this change, `thread/start ran` inline on the request loop, so
slow startup paths like MCP auth checks could hold up unrelated requests
on the same connection, including `thread/loaded/list`. This moves
`thread/start` into a background task.

While doing so, it revealed an issue where we were doing nested locking
(and there were some race conditions possible that could introduce a
"phantom listener"). This PR also refactors the listener/subscription
bookkeeping - listener/subscription state is now centralized in
`ThreadStateManager` instead of being split across multiple lock
domains. That makes late auto-attach on `thread/start` race-safe and
avoids reintroducing disconnected clients as phantom subscribers.
2026-02-28 01:40:08 +00:00
Eric Traut
6604608bad Suppress duplicate assistant output on stdout in interactive sessions (#13082)
Addresses #12566

Summary
- stop printing the final assistant message on stdout when the process
is running in a terminal so interactive users only see it once
- add a helper that gates the stdout emission and cover it with unit
tests
2026-02-27 18:31:17 -07:00
Ruslan Nigmatullin
70ed6cbc71 app-server: Add an ability to watch events in the test client (#13080)
Add a `watch` subcommand to `codex-app-server-test-client` binary to
help in manual testing of events flow.
2026-02-27 17:19:53 -08:00
Ahmed Ibrahim
ec6f6aacbf Add model availability NUX tooltips (#13021)
- override startup tooltips with model availability NUX and persist
per-model show counts in config
- stop showing each model after four exposures and fall back to normal
tooltips
2026-02-27 17:14:06 -08:00
Eric Traut
ff5cbfd7d4 Handle missing plan info for ChatGPT accounts (#13072)
Addresses https://github.com/openai/codex/issues/13007 and
https://github.com/openai/codex/issues/12170

There are situations where the ChatGPT auth backend might return a JWT
that contains no plan information. Most code paths already handle this
case well, but the internal implementation of the "account/read" app
server call was failing in this case (returning an error rather than
properly returning None for the plan).

This resulted in a situation where users needed to log in every time the
extension or app started even if they successfully logged in the last
time.

Summary
- allow ChatGPT-authenticated accounts to fall back to
`AccountPlanType::Unknown` when the token omits the plan claim
- add regression coverage in `app-server/tests/suite/v2/account.rs` to
confirm `account/read` returns `plan_type: Unknown` when the claim is
absent
- ensure the Rust auth helpers and fixtures treat missing plan claims as
Optional and default to `Unknown`
2026-02-27 17:51:21 -07:00
Eric Traut
61c42396ab Keep large-paste placeholders intact during file completion (#13070)
Addresses https://github.com/openai/codex/issues/13040

Fixes a regression in 0.106.0 introduced in
https://github.com/openai/codex/pull/9393

Summary
- replace only the active completion range so unrelated text elements
(e.g., large-paste placeholders) stay atomic and can still expand
- add a regression test verifying large paste placeholders persist
through completions and submit
- could not fetch issue details via GitHub API because network access is
disabled in this sandboxed environment
2026-02-27 17:19:11 -07:00
Felipe Coury
c3c75878e8 fix(tui): theme-aware diff backgrounds with fallback behavior (#13037)
## Problem

The TUI diff renderer uses hardcoded background palettes for
insert/delete lines that don't respect the user's chosen syntax theme.
When a theme defines `markup.inserted` / `markup.deleted` scope
backgrounds (the convention used by GitHub, Solarized, Monokai, and most
VS Code themes), those colors are ignored — the diff always renders with
the same green/red tints regardless of theme selection.

Separately, ANSI-16 terminals (and Windows Terminal sessions misreported
as ANSI-16) rendered diff backgrounds as full-saturation blocks that
obliterated syntax token colors, making highlighted diffs unreadable.

## Mental model

Diff backgrounds are resolved in three layers:

1. **Color level detection** — `diff_color_level_for_terminal()` maps
the raw `supports-color` probe + Windows Terminal heuristics to a
`DiffColorLevel` (TrueColor / Ansi256 / Ansi16). Windows Terminal gets
promoted from Ansi16 to TrueColor when `WT_SESSION` is present.

2. **Background resolution** — `resolve_diff_backgrounds()` queries the
active syntax theme for `markup.inserted`/`markup.deleted` (falling back
to `diff.inserted`/`diff.deleted`), then overlays those on top of the
hardcoded palette. For ANSI-256, theme RGB values are quantized to the
nearest xterm-256 index. For ANSI-16, backgrounds are `None`
(foreground-only).

3. **Style composition** — The resolved `ResolvedDiffBackgrounds` is
threaded through every call to `style_add`, `style_del`, `style_sign_*`,
and `style_line_bg_for`, which decide how to compose
foreground+background for each line kind and theme variant.

A new `RichDiffColorLevel` type (a subset of `DiffColorLevel` without
Ansi16) encodes the invariant "we have enough depth for tinted
backgrounds" at the type level, so background-producing functions have
exhaustive matches without unreachable arms.

## Non-goals

- No change to gutter (line number column) styling — gutter backgrounds
still use the hardcoded palette.
- No per-token scope background resolution — this is line-level
background only; syntax token colors come from the existing
`highlight_code_to_styled_spans` path.
- No dark/light theme auto-switching from scope backgrounds —
`DiffTheme` is still determined by querying the terminal's background
color.

## Tradeoffs

- **Theme trust vs. visual safety:** When a theme defines scope
backgrounds, we trust them unconditionally for rich color levels. A
badly authored theme could produce illegible combinations. The fallback
for `None` backgrounds (foreground-only) is intentionally conservative.
- **Quantization quality:** ANSI-256 quantization uses perceptual
distance across indices 16–255, skipping system colors. The result is
approximate — a subtle theme tint may land on a noticeably different
xterm index.
- **Single-query caching:** `resolve_diff_backgrounds` is called once
per `render_change` invocation (i.e., once per file in a diff). If the
theme changes mid-render (live preview), the next file picks up the new
backgrounds.

## Architecture

Files changed:

| File | Role |
|---|---|
| `tui/src/render/highlight.rs` | New: `DiffScopeBackgroundRgbs`,
`diff_scope_background_rgbs()`, scope extraction helpers |
| `tui/src/diff_render.rs` | New: `RichDiffColorLevel`,
`ResolvedDiffBackgrounds`, `resolve_diff_backgrounds*`,
`quantize_rgb_to_ansi256`, Windows Terminal promotion; modified: all
style helpers to accept/thread `ResolvedDiffBackgrounds` |

The scope-extraction code lives in `highlight.rs` because it uses
`syntect::highlighting::Highlighter` and the theme singleton. The
resolution and quantization logic lives in `diff_render.rs` because it
depends on diff-specific types (`DiffTheme`, `DiffColorLevel`, ratatui
`Color`).

## Observability

No runtime logging was added. The most useful debugging aid is the
`diff_color_level_for_terminal` function, which is pure and fully
unit-tested — to diagnose a color-depth mismatch, log its four inputs
(`StdoutColorLevel`, `TerminalName`, `WT_SESSION` presence,
`FORCE_COLOR` presence).

Scope resolution can be tested by loading a custom `.tmTheme` with known
`markup.inserted` / `markup.deleted` backgrounds and checking the diff
output in a truecolor terminal.

## Tests

- **Windows Terminal promotion:** 7 unit tests cover every branch of
`diff_color_level_for_terminal` (ANSI-16 promotion, `WT_SESSION`
unconditional promotion, `FORCE_COLOR` suppression, conservative
`Unknown` level).
- **ANSI-16 foreground-only:** Tests verify that `style_add`,
`style_del`, `style_sign_*`, `style_line_bg_for`, and `style_gutter_for`
all return `None` backgrounds on ANSI-16.
- **Scope resolution:** Tests verify `markup.*` preference over
`diff.*`, `None` when no scope matches, bundled theme resolution, and
custom `.tmTheme` round-trip.
- **Quantization:** Test verifies ANSI-256 quantization of a known RGB
triple.
- **Insta snapshots:** 2 new snapshot tests
(`ansi16_insert_delete_no_background`,
`theme_scope_background_resolution`) lock visual output.
2026-02-27 16:44:56 -07:00
viyatb-oai
a39d76dc45 feat(linux-sandbox): support restricted ReadOnlyAccess in bwrap (#12369)
## Summary
Implements Linux bubblewrap support for restricted `ReadOnlyAccess`
(introduced in #11387) by honoring `readable_roots` and
`include_platform_defaults` instead of failing closed.

## What changed
- Added a Linux platform-default read allowlist for common
system/runtime paths (e.g. /usr, /etc, /lib*, Nix store roots).
- Updated the bwrap filesystem mount builder to support restricted read
access:
  - Full-read policies still use `--ro-bind / /`
- Restricted-read policies now start from` --tmpfs `/ and add scoped
`--ro-bind` mounts
- Preserved existing writable-root and protected-subpath behavior
(`.git`, `.codex`, etc.).

`ReadOnlyAccess::Restricted` was already modeled in protocol, but Linux
bwrap still returned `UnsupportedOperation` for restricted read access.
This closes that gap for the active Linux filesystem backend.


## Notes
Legacy Linux Landlock fallback still fail-closes for restricted read
access (unchanged).
2026-02-27 15:25:50 -08:00
Matthew Zeng
392fa7de50 [apps] Stablize app list updated event. (#13067)
Stablize app list updated event so that we only send 2 updates: 1 when
installed apps become available, one when all directory apps are
available. Previously it also updates when directory apps become
available before installed apps, which cuts off installed apps.
2026-02-27 15:23:24 -08:00
Charley Cunningham
695957a348 Unify rollout reconstruction with resume/fork TurnContext hydration (#12612)
## Summary

This PR unifies rollout history reconstruction and resume/fork metadata
hydration under a single `Session::reconstruct_history_from_rollout`
implementation.

The key change from main is that replay metadata now comes from the same
reconstruction pass that rebuilds model-visible history, instead of
doing a second bespoke rollout scan to recover `previous_model` /
`reference_context_item`.

## What Changed

### Unified reconstruction output

`reconstruct_history_from_rollout` now returns a single
`RolloutReconstruction` bundle containing:

- rebuilt `history`
- `previous_model`
- `reference_context_item`

Resume and fork both consume that shared output directly.

### Reverse replay core

The reconstruction logic moved into
`codex-rs/core/src/codex/rollout_reconstruction.rs` and now scans
rollout items newest-to-oldest.

That reverse pass:

- derives `previous_model`
- derives whether `reference_context_item` is preserved or cleared
- stops early once it has both resume metadata and a surviving
`replacement_history` checkpoint

History materialization is still bridged eagerly for now by replaying
only the surviving suffix forward, which keeps the history result stable
while moving the control flow toward the future lazy reverse loader
design.

### Removed bespoke context lookup

This deletes `last_rollout_regular_turn_context_lookup` and its separate
compaction-aware scan.

The previous model / baseline metadata is now computed from the same
replay state that rebuilds history, so resume/fork cannot drift from the
reconstructed transcript view.

### `TurnContextItem` persistence contract

`TurnContextItem` is now treated as the replay source of truth for
durable model-visible baselines.

This PR keeps the following contract explicit:

- persist `TurnContextItem` for the first real user turn so resume can
recover `previous_model`
- persist it for later turns that emit model-visible context updates
- if mid-turn compaction reinjects full initial context into replacement
history, persist a fresh `TurnContextItem` after `Compacted` so
resume/fork can re-establish the baseline from the rewritten history
- do not treat manual compaction or pre-sampling compaction as creating
a new durable baseline on their own

## Behavior Preserved

- rollback replay stays aligned with `drop_last_n_user_turns`
- rollback skips only user turns
- incomplete active user turns are dropped before older finalized turns
when rollback applies
- unmatched aborts do not consume the current active turn
- missing abort IDs still conservatively clear stale compaction state
- compaction clears `reference_context_item` until a later
`TurnContextItem` re-establishes it
- `previous_model` still comes from the newest surviving user turn that
established one

## Tests

Targeted validation run for the current branch shape:

- `cd codex-rs && cargo test -p codex-core --lib
codex::rollout_reconstruction_tests -- --nocapture`
- `cd codex-rs && just fmt`

The branch also extracts the rollout reconstruction tests into
`codex-rs/core/src/codex/rollout_reconstruction_tests.rs` so this logic
has a dedicated home instead of living inline in `codex.rs`.
2026-02-27 13:50:45 -08:00
daniel-oai
6046ca19ba Clarify escalation guidance for sandbox-related network failures (#13051)
This updates the on-request permissions instructions so likely
sandbox-related network failures during dependency installation are
treated as escalation candidates.

Repro:
- Run `codex -a on-request -s workspace-write` in a fresh temp dir.
- Prompt: `Build a new rust app with one dependency, anyhow, and try
installing the dependency`.
- Before this change, DNS/registry failures like `Could not resolve
host: index.crates.io` could be treated like ordinary transient failures
and not escalate.

Fix:
- Clarify that likely sandbox-related network errors such as DNS/host
resolution, registry/index access, and dependency download failures
should trigger escalation.

Validation:
- Rebuild the CLI and rerun the same repro. The same instructions should
now be more likely to trigger escalation instead of silently stopping.

Related Slack canvas:
- https://openai.enterprise.slack.com/docs/T0BQTNSUF/F0ACVNJAV09
2026-02-27 13:48:52 -08:00
303 changed files with 42066 additions and 3346 deletions

View File

@@ -7,15 +7,17 @@ on:
- labeled
jobs:
gather-duplicates:
name: Identify potential duplicates
gather-duplicates-all:
name: Identify potential duplicates (all issues)
# 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:
codex_output: ${{ steps.select-final.outputs.codex_output }}
issues_json: ${{ steps.normalize-all.outputs.issues_json }}
reason: ${{ steps.normalize-all.outputs.reason }}
has_matches: ${{ steps.normalize-all.outputs.has_matches }}
steps:
- uses: actions/checkout@v6
@@ -29,7 +31,6 @@ 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 \
@@ -47,22 +48,6 @@ 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 \
@@ -71,7 +56,6 @@ 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.
@@ -158,9 +142,59 @@ 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 }}
@@ -200,7 +234,6 @@ 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 }}
@@ -243,15 +276,27 @@ 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: ${{ 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 }}
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 }}
run: |
set -eo pipefail
@@ -289,8 +334,8 @@ jobs:
comment-on-issue:
name: Comment with potential duplicates
needs: gather-duplicates
if: ${{ needs.gather-duplicates.result != 'skipped' }}
needs: select-final
if: ${{ needs.select-final.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -299,7 +344,7 @@ jobs:
- name: Comment on issue
uses: actions/github-script@v8
env:
CODEX_OUTPUT: ${{ needs.gather-duplicates.outputs.codex_output }}
CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }}
with:
github-token: ${{ github.token }}
script: |

View File

@@ -146,9 +146,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
git clone https://git.savannah.gnu.org/git/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
@@ -188,9 +187,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
git clone https://git.savannah.gnu.org/git/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

652
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,8 @@ members = [
"mcp-server",
"network-proxy",
"ollama",
"artifact-presentation",
"artifact-spreadsheet",
"process-hardening",
"protocol",
"rmcp-client",
@@ -109,6 +111,8 @@ 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" }
@@ -215,6 +219,7 @@ 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"
@@ -349,6 +354,7 @@ ignored = [
"openssl-sys",
"codex-utils-readiness",
"codex-secrets",
"codex-artifact-spreadsheet"
]
[profile.release]

View File

@@ -20,6 +20,7 @@ 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,6 +1703,12 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
@@ -1933,6 +1939,23 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -2155,6 +2178,23 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -2299,6 +2339,23 @@
"string",
"null"
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -2409,6 +2466,24 @@
],
"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,6 +1138,16 @@
],
"description": "How to sandbox commands executed in the system"
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"session_id": {
"$ref": "#/definitions/ThreadId"
},
@@ -4234,8 +4244,14 @@
{
"additionalProperties": false,
"properties": {
"SessionCreated": {
"SessionUpdated": {
"properties": {
"instructions": {
"type": [
"string",
"null"
]
},
"session_id": {
"type": "string"
}
@@ -4246,27 +4262,6 @@
"type": "object"
}
},
"required": [
"SessionCreated"
],
"title": "SessionCreatedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"SessionUpdated": {
"properties": {
"backend_prompt": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"required": [
"SessionUpdated"
],
@@ -4297,6 +4292,40 @@
"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": {
@@ -4312,6 +4341,47 @@
}
]
},
"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": [
@@ -5350,6 +5420,12 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionNetworkProxyRuntime": {
"properties": {
"admin_addr": {
@@ -6694,6 +6770,16 @@
],
"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,7 +70,18 @@
"method": {
"type": "string"
},
"params": true
"params": true,
"trace": {
"anyOf": [
{
"$ref": "#/definitions/W3cTraceContext"
},
{
"type": "null"
}
],
"description": "Optional W3C Trace Context for distributed tracing."
}
},
"required": [
"id",
@@ -102,6 +113,23 @@
"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,6 +11,23 @@
"type": "integer"
}
]
},
"W3cTraceContext": {
"properties": {
"traceparent": {
"type": [
"string",
"null"
]
},
"tracestate": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"description": "A request that expects a response.",
@@ -21,7 +38,18 @@
"method": {
"type": "string"
},
"params": true
"params": true,
"trace": {
"anyOf": [
{
"$ref": "#/definitions/W3cTraceContext"
},
{
"type": "null"
}
],
"description": "Optional W3C Trace Context for distributed tracing."
}
},
"required": [
"id",

View File

@@ -46,6 +46,16 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/PlanType"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -1655,6 +1665,10 @@
"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": [
{
@@ -1724,6 +1738,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -2306,6 +2306,16 @@
],
"description": "How to sandbox commands executed in the system"
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"session_id": {
"$ref": "#/definitions/v2/ThreadId"
},
@@ -5016,7 +5026,18 @@
"method": {
"type": "string"
},
"params": true
"params": true,
"trace": {
"anyOf": [
{
"$ref": "#/definitions/W3cTraceContext"
},
{
"type": "null"
}
],
"description": "Optional W3C Trace Context for distributed tracing."
}
},
"required": [
"id",
@@ -5443,8 +5464,14 @@
{
"additionalProperties": false,
"properties": {
"SessionCreated": {
"SessionUpdated": {
"properties": {
"instructions": {
"type": [
"string",
"null"
]
},
"session_id": {
"type": "string"
}
@@ -5455,27 +5482,6 @@
"type": "object"
}
},
"required": [
"SessionCreated"
],
"title": "SessionCreatedRealtimeEvent",
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"SessionUpdated": {
"properties": {
"backend_prompt": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"required": [
"SessionUpdated"
],
@@ -5506,6 +5512,40 @@
"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": {
@@ -5521,6 +5561,47 @@
}
]
},
"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": {
@@ -7220,6 +7301,23 @@
}
]
},
"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.",
@@ -7319,6 +7417,16 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/v2/PlanType"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
@@ -8497,6 +8605,16 @@
}
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"tools": {
"anyOf": [
{
@@ -10747,6 +10865,16 @@
}
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"web_search": {
"anyOf": [
{
@@ -11898,6 +12026,12 @@
"title": "ServerRequestResolvedNotification",
"type": "object"
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -12498,6 +12632,10 @@
"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": [
{
@@ -12567,6 +12705,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -12708,6 +12847,23 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -12746,6 +12902,16 @@
"sandbox": {
"$ref": "#/definitions/v2/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -13691,6 +13857,23 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}
@@ -13729,6 +13912,16 @@
"sandbox": {
"$ref": "#/definitions/v2/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -13907,6 +14100,23 @@
"string",
"null"
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
}
},
"title": "ThreadStartParams",
@@ -13940,6 +14150,16 @@
"sandbox": {
"$ref": "#/definitions/v2/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
@@ -14507,6 +14727,24 @@
],
"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,6 +26,20 @@
"type": "string"
}
]
},
"PlanType": {
"enum": [
"free",
"go",
"plus",
"pro",
"team",
"business",
"enterprise",
"edu",
"unknown"
],
"type": "string"
}
},
"properties": {
@@ -38,6 +52,16 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/PlanType"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",

View File

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

View File

@@ -50,6 +50,12 @@
"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.",
@@ -112,6 +118,23 @@
}
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
},
"threadId": {
"type": "string"
}

View File

@@ -738,6 +738,12 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -882,6 +888,10 @@
"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": [
{
@@ -951,6 +961,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -1901,6 +1912,16 @@
"sandbox": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/Thread"
}

View File

@@ -655,6 +655,10 @@
"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": [
{
@@ -724,6 +728,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -655,6 +655,10 @@
"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": [
{
@@ -724,6 +728,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

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

View File

@@ -738,6 +738,12 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -882,6 +888,10 @@
"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": [
{
@@ -951,6 +961,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -1901,6 +1912,16 @@
"sandbox": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/Thread"
}

View File

@@ -655,6 +655,10 @@
"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": [
{
@@ -724,6 +728,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

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

View File

@@ -738,6 +738,12 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -882,6 +888,10 @@
"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": [
{
@@ -951,6 +961,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",
@@ -1901,6 +1912,16 @@
"sandbox": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
"thread": {
"$ref": "#/definitions/Thread"
}

View File

@@ -655,6 +655,10 @@
"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": [
{
@@ -724,6 +728,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -655,6 +655,10 @@
"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": [
{
@@ -724,6 +728,7 @@
"cliVersion",
"createdAt",
"cwd",
"ephemeral",
"id",
"modelProvider",
"preview",

View File

@@ -299,6 +299,12 @@
}
]
},
"ServiceTier": {
"enum": [
"fast"
],
"type": "string"
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
@@ -539,6 +545,24 @@
],
"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,6 +2,7 @@
// 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 = { "SessionCreated": { session_id: string, } } | { "SessionUpdated": { backend_prompt: string | null, } } | { "AudioOut": RealtimeAudioFrame } | { "ConversationItemAdded": JsonValue } | { "Error": string };
export type RealtimeEvent = { "SessionUpdated": { session_id: string, instructions: string | null, } } | { "AudioOut": RealtimeAudioFrame } | { "ConversationItemAdded": JsonValue } | { "ConversationItemDone": { item_id: string, } } | { "HandoffRequested": RealtimeHandoffRequested } | { "Error": string };

View File

@@ -0,0 +1,5 @@
// 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

@@ -0,0 +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 { RealtimeHandoffMessage } from "./RealtimeHandoffMessage";
export type RealtimeHandoffRequested = { handoff_id: string, item_id: string, input_transcript: string, messages: Array<RealtimeHandoffMessage>, };

View File

@@ -6,10 +6,11 @@ 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, effort: ReasoningEffort | null, summary: ReasoningSummary,
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,
/**
* Optional JSON Schema used to constrain the final assistant message for this turn.
*/

View File

@@ -0,0 +1,5 @@
// 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,6 +5,7 @@ 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";
@@ -16,7 +17,7 @@ thread_name?: string,
/**
* Tell the client what model is being queried.
*/
model: string, model_provider_id: string,
model: string, model_provider_id: string, service_tier: ServiceTier | null,
/**
* When to escalate for approval for execution
*/

View File

@@ -3,6 +3,7 @@
// 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, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array<EventMsg> | null, rolloutPath: string, };
export type SessionConfiguredNotification = { sessionId: ThreadId, model: string, serviceTier: ServiceTier | null, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array<EventMsg> | null, rolloutPath: string, };

View File

@@ -161,6 +161,8 @@ 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";
@@ -198,6 +200,7 @@ 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,5 +2,6 @@
// 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, };
export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, };

View File

@@ -4,6 +4,7 @@
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";
@@ -14,4 +15,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, 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, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });

View File

@@ -3,9 +3,10 @@
// 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, 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, 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 });

View File

@@ -11,6 +11,10 @@ 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,6 +1,7 @@
// 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";
@@ -21,7 +22,7 @@ export type ThreadForkParams = {threadId: string, /**
path?: string | null, /**
* Configuration overrides for the forked thread, if any.
*/
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, /**
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, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/

View File

@@ -2,8 +2,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 { 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, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };

View File

@@ -3,6 +3,7 @@
// 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";
@@ -30,7 +31,7 @@ history?: Array<ResponseItem> | null, /**
path?: string | null, /**
* Configuration overrides for the resumed thread, if any.
*/
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, /**
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, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/

View File

@@ -2,8 +2,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 { 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, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };

View File

@@ -2,11 +2,12 @@
// 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, 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, 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, /**
* 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,8 +2,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 { 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, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, };

View File

@@ -5,6 +5,7 @@ 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";
@@ -23,6 +24,9 @@ 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

@@ -1,6 +1,7 @@
//! 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;
@@ -38,6 +39,10 @@ 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

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

View File

@@ -0,0 +1,23 @@
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

@@ -5,6 +5,7 @@ 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;
@@ -419,6 +420,13 @@ 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.
@@ -429,6 +437,55 @@ 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 {
@@ -555,6 +612,7 @@ 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

@@ -17,6 +17,7 @@ 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;
@@ -392,6 +393,7 @@ 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>,
@@ -503,6 +505,7 @@ 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)]
@@ -1788,6 +1791,14 @@ 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)]
@@ -1850,6 +1861,7 @@ 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,
@@ -1891,6 +1903,14 @@ 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)]
@@ -1919,6 +1939,7 @@ 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,
@@ -1951,6 +1972,14 @@ 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)]
@@ -1977,6 +2006,7 @@ 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,
@@ -2517,6 +2547,8 @@ 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.
@@ -2555,6 +2587,7 @@ 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)]
@@ -2834,6 +2867,15 @@ 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>,
@@ -4563,4 +4605,56 @@ 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,6 +18,15 @@ 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,6 +15,7 @@ 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;
@@ -71,6 +72,7 @@ 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;
@@ -104,6 +106,8 @@ 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)]
@@ -188,6 +192,10 @@ 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 {
@@ -291,6 +299,11 @@ 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)
@@ -489,25 +502,26 @@ fn send_message(
config_overrides: &[String],
user_message: String,
) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let conversation = client.start_thread()?;
println!("< newConversation response: {conversation:?}");
let conversation = client.start_thread()?;
println!("< newConversation response: {conversation:?}");
let subscription = client.add_conversation_listener(&conversation.conversation_id)?;
println!("< addConversationListener response: {subscription:?}");
let subscription = client.add_conversation_listener(&conversation.conversation_id)?;
println!("< addConversationListener response: {subscription:?}");
let send_response =
client.send_user_message(&conversation.conversation_id, &user_message)?;
println!("< sendUserMessage response: {send_response:?}");
let send_response = client.send_user_message(&conversation.conversation_id, &user_message)?;
println!("< sendUserMessage response: {send_response:?}");
client.stream_conversation(&conversation.conversation_id)?;
client.stream_conversation(&conversation.conversation_id)?;
client.remove_thread_listener(subscription.subscription_id)?;
client.remove_thread_listener(subscription.subscription_id)?;
Ok(())
Ok(())
})
}
pub fn send_message_v2(
@@ -565,82 +579,85 @@ 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());
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
with_client(endpoint, config_overrides, |client| {
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) {
if client.command_approval_count < min_approvals {
bail!(
"expected completed turn in all-accept flow, got {:?}",
client.last_turn_status
"expected at least {min_approvals} command approvals, got {}",
client.command_approval_count
);
}
} else if last_command_status == Some(&CommandExecutionStatus::Completed) {
bail!(
"expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}"
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
);
}
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(())
Ok(())
})
}
fn resume_message_v2(
@@ -652,30 +669,30 @@ fn resume_message_v2(
) -> Result<()> {
ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?;
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let resume_response = client.thread_resume(ThreadResumeParams {
thread_id,
..Default::default()
})?;
println!("< thread/resume response: {resume_response:?}");
let resume_response = client.thread_resume(ThreadResumeParams {
thread_id,
..Default::default()
})?;
println!("< thread/resume response: {resume_response:?}");
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 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:?}");
client.stream_turn(&resume_response.thread.id, &turn_response.turn.id)?;
client.stream_turn(&resume_response.thread.id, &turn_response.turn.id)?;
Ok(())
Ok(())
})
}
fn thread_resume_follow(
@@ -698,6 +715,16 @@ 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],
@@ -768,34 +795,34 @@ fn send_message_v2_with_policies(
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize_with_experimental_api(experimental_api)?;
println!("< initialize response: {initialize:?}");
let initialize = client.initialize_with_experimental_api(experimental_api)?;
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 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 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 turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
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)?;
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
Ok(())
Ok(())
})
}
fn send_follow_up_v2(
@@ -805,119 +832,130 @@ fn send_follow_up_v2(
follow_up_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
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:?}");
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 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 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 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(())
Ok(())
})
}
fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
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 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")
);
}
})
}
fn get_account_rate_limits(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let response = client.get_account_rate_limits()?;
println!("< account/rateLimits/read response: {response:?}");
let response = client.get_account_rate_limits()?;
println!("< account/rateLimits/read response: {response:?}");
Ok(())
Ok(())
})
}
fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let response = client.model_list(ModelListParams::default())?;
println!("< model/list response: {response:?}");
let response = client.model_list(ModelListParams::default())?;
println!("< model/list response: {response:?}");
Ok(())
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 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(())
let result = f(&mut client);
client.print_trace_summary();
result
}
fn ensure_dynamic_tools_unused(
@@ -974,6 +1012,8 @@ 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)]
@@ -1033,6 +1073,8 @@ 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(),
})
}
@@ -1054,6 +1096,8 @@ 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(),
})
}
@@ -1419,12 +1463,32 @@ impl CodexClient {
}
fn write_request(&mut self, request: &ClientRequest) -> Result<()> {
let request_json = serde_json::to_string(request)?;
let request_pretty = serde_json::to_string_pretty(request)?;
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)?;
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,
@@ -1690,6 +1754,15 @@ 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}");
@@ -1709,11 +1782,18 @@ impl Drop for CodexClient {
return;
}
thread::sleep(Duration::from_millis(100));
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;
}
if let Ok(Some(status)) = child.try_wait() {
println!("[codex app-server exited: {status}]");
return;
if SystemTime::now() >= deadline {
break;
}
thread::sleep(APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL);
}
let _ = child.kill();

View File

@@ -21,6 +21,7 @@ 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,7 +62,8 @@ 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.
- 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.
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.
- 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.
@@ -119,16 +120,16 @@ Example with notification opt-out:
## API Overview
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `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/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` 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` (including the current `thread.status`) 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; 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 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/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.
@@ -272,10 +273,11 @@ 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:
`thread/status/changed` is emitted whenever a loaded thread's status changes after it has already been introduced to the client:
- 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": {
@@ -619,7 +621,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 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`.
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`.
- `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? } }`.
@@ -951,7 +953,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`) and can be inferred from `account/read`.
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`.
- **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.
@@ -963,7 +965,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`).
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
- `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? }`.
@@ -1007,7 +1009,7 @@ Field notes:
3. Notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "apikey" } }
{ "method": "account/updated", "params": { "authMode": "apikey", "planType": null } }
```
### 3) Log in with ChatGPT (browser flow)
@@ -1021,7 +1023,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" } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
```
### 4) Cancel a ChatGPT login
@@ -1036,7 +1038,7 @@ Field notes:
```json
{ "method": "account/logout", "id": 5 }
{ "id": 5, "result": {} }
{ "method": "account/updated", "params": { "authMode": null } }
{ "method": "account/updated", "params": { "authMode": null, "planType": null } }
```
### 6) Rate limits (ChatGPT)

View File

@@ -0,0 +1,101 @@
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

@@ -83,6 +83,7 @@ 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;
@@ -185,12 +186,30 @@ pub(crate) async fn apply_bespoke_event_handling(
msg,
} = event;
match msg {
EventMsg::TurnStarted(_) => {
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;
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.
@@ -232,7 +251,6 @@ 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 {
@@ -256,6 +274,24 @@ 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(),

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@ 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;
@@ -447,7 +448,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| {
@@ -557,7 +558,6 @@ 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,6 +675,7 @@ pub async fn run_main_with_transport(
.process_request(
connection_id,
request,
transport,
&mut connection_state.session,
&connection_state.outbound_initialized,
)

View File

@@ -12,6 +12,7 @@ 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;
@@ -59,6 +60,7 @@ 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);
@@ -141,13 +143,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,
@@ -163,7 +165,6 @@ impl MessageProcessor {
outgoing,
arg0_paths,
config,
single_client_mode,
cli_overrides,
loader_overrides,
cloud_requirements,
@@ -199,7 +200,6 @@ 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,46 +224,50 @@ impl MessageProcessor {
&mut self,
connection_id: ConnectionId,
request: JSONRPCRequest,
transport: AppServerTransport,
session: &mut ConnectionSessionState,
outbound_initialized: &AtomicBool,
) {
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_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 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 } => {
@@ -304,6 +308,8 @@ 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 => {
@@ -330,7 +336,6 @@ 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 };
@@ -338,6 +343,9 @@ impl MessageProcessor {
session.initialized = true;
outbound_initialized.store(true, Ordering::Release);
self.codex_message_processor
.connection_initialized(connection_id)
.await;
return;
}
}
@@ -352,91 +360,97 @@ 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;
}
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()
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;
}
}
}
.instrument(request_span)
.await;
}
pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) {

View File

@@ -612,6 +612,7 @@ 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);
@@ -619,7 +620,8 @@ mod tests {
json!({
"method": "account/updated",
"params": {
"authMode": "apikey"
"authMode": "apikey",
"planType": null
},
}),
serde_json::to_value(jsonrpc_notification)

View File

@@ -60,7 +60,6 @@ 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 {
@@ -95,18 +94,6 @@ 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;
}
@@ -135,55 +122,112 @@ 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)]
pub(crate) struct ThreadStateManager {
thread_states: HashMap<ThreadId, Arc<Mutex<ThreadState>>>,
struct ThreadStateManagerInner {
live_connections: HashSet<ConnectionId>,
threads: HashMap<ThreadId, ThreadEntry>,
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) 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 connection_initialized(&self, connection_id: ConnectionId) {
self.state
.lock()
.await
.live_connections
.insert(connection_id);
}
pub(crate) async fn remove_listener(&mut self, subscription_id: Uuid) -> Option<ThreadId> {
let subscription_state = self.subscription_state_by_id.remove(&subscription_id)?;
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,
)
};
let thread_id = subscription_state.thread_id;
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() {
if let Some((no_subscribers, thread_state)) = thread_state {
let thread_state = thread_state.lock().await;
if !connection_still_subscribed_to_thread && no_subscribers {
tracing::debug!(
thread_id = %thread_id,
subscription_id = %subscription_id,
@@ -196,8 +240,24 @@ impl ThreadStateManager {
Some(thread_id)
}
pub(crate) async fn remove_thread_state(&mut self, thread_id: ThreadId) {
if let Some(thread_state) = self.thread_states.remove(&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 {
let mut thread_state = thread_state.lock().await;
tracing::debug!(
thread_id = %thread_id,
@@ -208,142 +268,189 @@ 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(
&mut self,
&self,
thread_id: ThreadId,
connection_id: ConnectionId,
) -> bool {
let Some(thread_state) = self.thread_states.get(&thread_id) else {
return false;
{
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)
});
};
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 {
let Some(thread_state) = self.thread_states.get(&thread_id) else {
return false;
};
!thread_state
self.state
.lock()
.await
.subscribed_connection_ids()
.is_empty()
.threads
.get(&thread_id)
.is_some_and(|thread_entry| !thread_entry.connection_ids.is_empty())
}
pub(crate) async fn set_listener(
&mut self,
&self,
subscription_id: Uuid,
thread_id: ThreadId,
connection_id: ConnectionId,
experimental_raw_events: bool,
) -> Arc<Mutex<ThreadState>> {
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 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()
};
{
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 ensure_connection_subscribed(
&mut self,
pub(crate) async fn try_ensure_connection_subscribed(
&self,
thread_id: ThreadId,
connection_id: ConnectionId,
experimental_raw_events: bool,
) -> Arc<Mutex<ThreadState>> {
self.thread_ids_by_connection
.entry(connection_id)
.or_default()
.insert(thread_id);
let thread_state = self.thread_state(thread_id);
) -> 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()
};
{
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);
}
}
thread_state
Some(thread_state)
}
pub(crate) async fn remove_connection(&mut self, connection_id: ConnectionId) {
let thread_ids = self
.thread_ids_by_connection
.remove(&connection_id)
.unwrap_or_default();
self.subscription_state_by_id
.retain(|_, state| state.connection_id != 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"
);
}
}
return;
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
.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
}
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"
);
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);
}
}
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<_>>()
};
for (thread_id, no_subscribers, thread_state) in thread_states {
if !no_subscribers {
continue;
}
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,7 +91,12 @@ impl ThreadWatchManager {
}
pub(crate) async fn upsert_thread(&self, thread: Thread) {
self.mutate_and_publish(move |state| state.upsert_thread(thread.id))
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))
.await;
}
@@ -289,14 +294,22 @@ struct ThreadWatchState {
}
impl ThreadWatchState {
fn upsert_thread(&mut self, thread_id: String) -> Option<ThreadStatusChangedNotification> {
fn upsert_thread(
&mut self,
thread_id: String,
emit_notification: bool,
) -> 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;
self.status_changed_notification(thread_id, previous_status)
if emit_notification {
self.status_changed_notification(thread_id, previous_status)
} else {
None
}
}
fn remove_thread(&mut self, thread_id: &str) -> Option<ThreadStatusChangedNotification> {
@@ -692,6 +705,45 @@ 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,
@@ -733,6 +785,7 @@ 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,6 +744,7 @@ 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
@@ -885,6 +886,7 @@ 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,6 +891,7 @@ 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,6 +84,7 @@ 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,
@@ -165,6 +166,7 @@ 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(20);
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_codex_jsonrpc_conversation_flow() -> Result<()> {
@@ -337,6 +337,7 @@ 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?;
@@ -453,6 +454,7 @@ 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?;
@@ -481,6 +483,7 @@ 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,6 +92,7 @@ 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?;
@@ -184,6 +185,7 @@ 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?;
@@ -273,6 +275,7 @@ 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?;
@@ -321,6 +324,7 @@ 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,6 +628,7 @@ 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,6 +131,7 @@ 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(),
@@ -201,6 +202,7 @@ 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 {
@@ -843,6 +845,7 @@ 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(())
@@ -1227,3 +1230,45 @@ 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_returns_connectors_with_accessible_flags() -> Result<()> {
async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() -> Result<()> {
let connectors = vec![
AppInfo {
id: "alpha".to_string(),
@@ -475,7 +475,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_user_id("user-directory-first")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
@@ -492,60 +492,14 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
})
.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.
let maybe_update = timeout(
Duration::from_millis(150),
read_app_list_updated_notification(&mut mcp),
)
.await;
assert!(
first_update.data == expected_directory_first
|| first_update.data == expected_accessible_first,
"unexpected first app/list update: {:#?}",
first_update.data
maybe_update.is_err(),
"unexpected directory-only app/list update before accessible apps loaded"
);
let expected = vec![
@@ -579,8 +533,96 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
},
];
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(second_update.data, expected);
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 response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
@@ -1026,39 +1068,14 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
]
);
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 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 expected_final = vec![AppInfo {
@@ -1075,8 +1092,8 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
is_accessible: false,
is_enabled: true,
}];
let third_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(third_update.data, expected_final);
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(second_update.data, expected_final);
let refetch_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,

View File

@@ -174,6 +174,7 @@ 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,6 +5,7 @@ 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;
@@ -42,20 +43,17 @@ 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.created",
"session": { "id": "sess_backend" }
})],
vec![json!({
"type": "session.updated",
"session": { "backend_prompt": "backend prompt" }
"session": { "id": "sess_backend", "instructions": "backend prompt" }
})],
vec![],
vec![
json!({
"type": "response.output_audio.delta",
"type": "conversation.output_audio.delta",
"delta": "AQID",
"sample_rate": 24_000,
"num_channels": 1,
"channels": 1,
"samples_per_channel": 512
}),
json!({
@@ -84,6 +82,7 @@ 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())
@@ -182,7 +181,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.create")
Some("session.update")
);
let mut request_types = [
connection[1].body_json()["type"]
@@ -199,7 +198,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
request_types,
[
"conversation.item.create".to_string(),
"response.input_audio.delta".to_string(),
"input_audio_buffer.append".to_string(),
]
);
@@ -214,8 +213,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.created",
"session": { "id": "sess_backend" }
"type": "session.updated",
"session": { "id": "sess_backend", "instructions": "backend prompt" }
})],
vec![],
]])
@@ -231,6 +230,7 @@ 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,6 +349,22 @@ 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

@@ -8,6 +8,7 @@ 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;
@@ -19,9 +20,12 @@ 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;
@@ -301,6 +305,31 @@ 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(())
}
@@ -389,6 +418,11 @@ 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::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
@@ -15,6 +15,7 @@ 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;
@@ -124,11 +125,27 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
}
// A corresponding thread/started notification should arrive.
let notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
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 started_params = notif.params.clone().expect("params must be present");
let started_thread_json = started_params
.get("thread")

View File

@@ -10,6 +10,7 @@ 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;
@@ -78,6 +79,7 @@ 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");
@@ -219,25 +221,6 @@ 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
@@ -252,6 +235,15 @@ 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
@@ -278,6 +270,11 @@ 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
@@ -317,6 +314,11 @@ 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
@@ -345,6 +347,11 @@ 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,4 +1,3 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_apply_patch_sse_response;
@@ -17,7 +16,6 @@ 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::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
@@ -30,7 +28,6 @@ use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
@@ -293,7 +290,7 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> {
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
wait_for_thread_status_active(&mut primary, &thread.id),
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
@@ -400,7 +397,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
to_response::<TurnStartResponse>(running_turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
wait_for_thread_status_active(&mut primary, &thread_id),
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
@@ -516,7 +513,7 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
to_response::<TurnStartResponse>(running_turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
wait_for_thread_status_active(&mut primary, &thread_id),
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
@@ -619,7 +616,7 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
wait_for_thread_status_active(&mut primary, &thread.id),
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
@@ -1419,30 +1416,6 @@ required = true
)
}
async fn wait_for_thread_status_active(
mcp: &mut McpProcess,
thread_id: &str,
) -> Result<ThreadStatusChangedNotification> {
loop {
let status_changed_notif: JSONRPCNotification = mcp
.read_stream_until_notification_message("thread/status/changed")
.await?;
let status_changed_params = status_changed_notif
.params
.context("thread/status/changed params must be present")?;
let status_changed: ThreadStatusChangedNotification =
serde_json::from_value(status_changed_params)?;
if status_changed.thread_id == thread_id
&& status_changed.status
== (ThreadStatus::Active {
active_flags: Vec::new(),
})
{
return Ok(status_changed);
}
}
}
#[allow(dead_code)]
fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
let parsed = chrono::DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc);

View File

@@ -3,16 +3,18 @@ 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::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCMessage;
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;
@@ -62,6 +64,10 @@ 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");
@@ -80,14 +86,35 @@ 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 notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
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 started_params = notif.params.clone().expect("params must be present");
let started_thread_json = started_params
.get("thread")
@@ -98,6 +125,13 @@ 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);
@@ -196,11 +230,25 @@ 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

@@ -434,6 +434,21 @@ 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
@@ -457,25 +472,30 @@ 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);
// Expect a second turn/started notification as well.
let _notif2: JSONRPCNotification = timeout(
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_notif: JSONRPCNotification = timeout(
let completed_notif2: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let completed: TurnCompletedNotification = serde_json::from_value(
completed_notif
let completed2: TurnCompletedNotification = serde_json::from_value(
completed_notif2
.params
.expect("turn/completed params must be present"),
)?;
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
assert_eq!(completed2.thread_id, thread.id);
assert_eq!(completed2.turn.id, turn2.id);
assert_eq!(completed2.turn.status, TurnStatus::Completed);
Ok(())
}
@@ -1365,6 +1385,7 @@ 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,
@@ -1396,6 +1417,7 @@ 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,

View File

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

View File

@@ -0,0 +1,28 @@
[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

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

View File

@@ -0,0 +1,249 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use image::GenericImageView;
use image::ImageFormat;
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use ppt_rs::Chart;
use ppt_rs::ChartSeries;
use ppt_rs::ChartType;
use ppt_rs::Hyperlink as PptHyperlink;
use ppt_rs::HyperlinkAction as PptHyperlinkAction;
use ppt_rs::Image;
use ppt_rs::Presentation;
use ppt_rs::Shape;
use ppt_rs::ShapeFill;
use ppt_rs::ShapeLine;
use ppt_rs::ShapeType;
use ppt_rs::SlideContent;
use ppt_rs::SlideLayout;
use ppt_rs::TableBuilder;
use ppt_rs::TableCell;
use ppt_rs::TableRow;
use ppt_rs::generator::ArrowSize;
use ppt_rs::generator::ArrowType;
use ppt_rs::generator::CellAlign;
use ppt_rs::generator::Connector;
use ppt_rs::generator::ConnectorLine;
use ppt_rs::generator::ConnectorType;
use ppt_rs::generator::LineDash;
use ppt_rs::generator::generate_image_content_type;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Cursor;
use std::io::Read;
use std::io::Seek;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use thiserror::Error;
use uuid::Uuid;
use zip::ZipArchive;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
const POINT_TO_EMU: u32 = 12_700;
const DEFAULT_SLIDE_WIDTH_POINTS: u32 = 720;
const DEFAULT_SLIDE_HEIGHT_POINTS: u32 = 540;
const DEFAULT_IMPORTED_TITLE_LEFT: u32 = 36;
const DEFAULT_IMPORTED_TITLE_TOP: u32 = 24;
const DEFAULT_IMPORTED_TITLE_WIDTH: u32 = 648;
const DEFAULT_IMPORTED_TITLE_HEIGHT: u32 = 48;
const DEFAULT_IMPORTED_CONTENT_LEFT: u32 = 48;
const DEFAULT_IMPORTED_CONTENT_TOP: u32 = 96;
const DEFAULT_IMPORTED_CONTENT_WIDTH: u32 = 624;
const DEFAULT_IMPORTED_CONTENT_HEIGHT: u32 = 324;
#[derive(Debug, Error)]
pub enum PresentationArtifactError {
#[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("unsupported feature for action `{action}`: {message}")]
UnsupportedFeature { action: String, message: String },
#[error("failed to import PPTX `{path}`: {message}")]
ImportFailed { path: PathBuf, message: String },
#[error("failed to export PPTX `{path}`: {message}")]
ExportFailed { path: PathBuf, message: String },
}
#[derive(Debug, Clone, Deserialize)]
pub struct PresentationArtifactRequest {
pub artifact_id: Option<String>,
pub action: String,
#[serde(default)]
pub args: Value,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PresentationArtifactToolRequest {
pub artifact_id: Option<String>,
pub actions: Vec<PresentationArtifactToolAction>,
}
#[derive(Debug, Clone)]
pub struct PresentationArtifactExecutionRequest {
pub artifact_id: Option<String>,
pub requests: Vec<PresentationArtifactRequest>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PresentationArtifactToolAction {
pub action: String,
#[serde(default)]
pub args: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathAccessKind {
Read,
Write,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathAccessRequirement {
pub action: String,
pub kind: PathAccessKind,
pub path: PathBuf,
}
impl PresentationArtifactRequest {
pub fn is_mutating(&self) -> bool {
!is_read_only_action(&self.action)
}
pub fn required_path_accesses(
&self,
cwd: &Path,
) -> Result<Vec<PathAccessRequirement>, PresentationArtifactError> {
let access = match self.action.as_str() {
"import_pptx" => {
let args: ImportPptxArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, &args.path),
}]
}
"export_pptx" => {
let args: ExportPptxArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Write,
path: resolve_path(cwd, &args.path),
}]
}
"export_preview" => {
let args: ExportPreviewArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Write,
path: resolve_path(cwd, &args.path),
}]
}
"add_image" => {
let args: AddImageArgs = parse_args(&self.action, &self.args)?;
match args.image_source()? {
ImageInputSource::Path(path) => vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, &path),
}],
ImageInputSource::DataUrl(_)
| ImageInputSource::Blob(_)
| ImageInputSource::Uri(_)
| ImageInputSource::Placeholder => Vec::new(),
}
}
"replace_image" => {
let args: ReplaceImageArgs = parse_args(&self.action, &self.args)?;
match (
&args.path,
&args.data_url,
&args.blob,
&args.uri,
&args.prompt,
) {
(Some(path), None, None, None, None) => vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, path),
}],
(None, Some(_), None, None, None)
| (None, None, Some(_), None, None)
| (None, None, None, Some(_), None)
| (None, None, None, None, Some(_)) => Vec::new(),
_ => {
return Err(PresentationArtifactError::InvalidArgs {
action: self.action.clone(),
message:
"provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image"
.to_string(),
});
}
}
}
_ => Vec::new(),
};
Ok(access)
}
}
impl PresentationArtifactToolRequest {
pub fn is_mutating(&self) -> Result<bool, PresentationArtifactError> {
Ok(self.actions.iter().any(|request| !is_read_only_action(&request.action)))
}
pub fn into_execution_request(
self,
) -> Result<PresentationArtifactExecutionRequest, PresentationArtifactError> {
if self.actions.is_empty() {
return Err(PresentationArtifactError::InvalidArgs {
action: "presentation_artifact".to_string(),
message: "`actions` must contain at least one item".to_string(),
});
}
Ok(PresentationArtifactExecutionRequest {
artifact_id: self.artifact_id,
requests: self
.actions
.into_iter()
.map(|request| PresentationArtifactRequest {
artifact_id: None,
action: request.action,
args: request.args,
})
.collect(),
})
}
pub fn required_path_accesses(
&self,
cwd: &Path,
) -> Result<Vec<PathAccessRequirement>, PresentationArtifactError> {
let mut accesses = Vec::new();
for request in &self.actions {
accesses.extend(
PresentationArtifactRequest {
artifact_id: None,
action: request.action.clone(),
args: request.args.clone(),
}
.required_path_accesses(cwd)?,
);
}
Ok(accesses)
}
}

View File

@@ -0,0 +1,729 @@
#[derive(Debug, Deserialize)]
struct CreateArgs {
name: Option<String>,
slide_size: Option<Value>,
theme: Option<ThemeArgs>,
}
#[derive(Debug, Deserialize)]
struct ImportPptxArgs {
path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct ExportPptxArgs {
path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct ExportPreviewArgs {
path: PathBuf,
slide_index: Option<u32>,
format: Option<String>,
scale: Option<f32>,
quality: Option<u8>,
}
#[derive(Debug, Default, Deserialize)]
struct AddSlideArgs {
layout: Option<String>,
notes: Option<String>,
background_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateLayoutArgs {
name: String,
kind: Option<String>,
parent_layout_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PreviewOutputFormat {
Png,
Jpeg,
Svg,
}
impl PreviewOutputFormat {
fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Svg => "svg",
}
}
}
#[derive(Debug, Deserialize)]
struct AddLayoutPlaceholderArgs {
layout_id: String,
name: String,
placeholder_type: String,
index: Option<u32>,
text: Option<String>,
geometry: Option<String>,
position: Option<PositionArgs>,
}
#[derive(Debug, Deserialize)]
struct LayoutIdArgs {
layout_id: String,
}
#[derive(Debug, Deserialize)]
struct SetSlideLayoutArgs {
slide_index: u32,
layout_id: String,
}
#[derive(Debug, Deserialize)]
struct UpdatePlaceholderTextArgs {
slide_index: u32,
name: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct NotesArgs {
slide_index: u32,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NotesVisibilityArgs {
slide_index: u32,
visible: bool,
}
#[derive(Debug, Deserialize)]
struct ThemeArgs {
color_scheme: HashMap<String, String>,
major_font: Option<String>,
minor_font: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StyleNameArgs {
name: String,
}
#[derive(Debug, Deserialize)]
struct AddStyleArgs {
name: String,
#[serde(flatten)]
styling: TextStylingArgs,
}
#[derive(Debug, Deserialize)]
struct InspectArgs {
kind: Option<String>,
include: Option<String>,
exclude: Option<String>,
search: Option<String>,
target_id: Option<String>,
target: Option<InspectTargetArgs>,
max_chars: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
struct InspectTargetArgs {
id: String,
before_lines: Option<usize>,
after_lines: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ResolveArgs {
id: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PatchOperationInput {
artifact_id: Option<String>,
action: String,
#[serde(default)]
args: Value,
}
#[derive(Debug, Deserialize)]
struct RecordPatchArgs {
operations: Vec<PatchOperationInput>,
}
#[derive(Debug, Deserialize)]
struct ApplyPatchArgs {
operations: Option<Vec<PatchOperationInput>>,
patch: Option<PresentationPatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PresentationPatch {
version: u32,
artifact_id: String,
operations: Vec<PatchOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PatchOperation {
action: String,
#[serde(default)]
args: Value,
}
#[derive(Debug, Default, Deserialize)]
struct InsertSlideArgs {
index: Option<u32>,
after_slide_index: Option<u32>,
layout: Option<String>,
notes: Option<String>,
background_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SlideIndexArgs {
slide_index: u32,
}
#[derive(Debug, Deserialize)]
struct MoveSlideArgs {
from_index: u32,
to_index: u32,
}
#[derive(Debug, Deserialize)]
struct SetActiveSlideArgs {
slide_index: u32,
}
#[derive(Debug, Deserialize)]
struct SetSlideBackgroundArgs {
slide_index: u32,
fill: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PositionArgs {
left: u32,
top: u32,
width: u32,
height: u32,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PartialPositionArgs {
left: Option<u32>,
top: Option<u32>,
width: Option<u32>,
height: Option<u32>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct TextStylingArgs {
style: Option<String>,
font_size: Option<u32>,
font_family: Option<String>,
color: Option<String>,
fill: Option<String>,
alignment: Option<String>,
bold: Option<bool>,
italic: Option<bool>,
underline: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct TextLayoutArgs {
insets: Option<TextInsetsArgs>,
wrap: Option<String>,
auto_fit: Option<String>,
vertical_alignment: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TextInsetsArgs {
left: u32,
right: u32,
top: u32,
bottom: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RichTextInput {
Plain(String),
Paragraphs(Vec<RichParagraphInput>),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RichParagraphInput {
Plain(String),
Runs(Vec<RichRunInput>),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RichRunInput {
Plain(String),
Styled(RichRunObjectInput),
}
#[derive(Debug, Clone, Deserialize)]
struct RichRunObjectInput {
run: String,
#[serde(default)]
text_style: TextStylingArgs,
link: Option<RichTextLinkInput>,
}
#[derive(Debug, Clone, Deserialize)]
struct RichTextLinkInput {
uri: Option<String>,
is_external: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AddTextShapeArgs {
slide_index: u32,
text: String,
position: PositionArgs,
#[serde(flatten)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct StrokeArgs {
color: String,
width: u32,
style: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddShapeArgs {
slide_index: u32,
geometry: String,
position: PositionArgs,
fill: Option<String>,
stroke: Option<StrokeArgs>,
text: Option<String>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
#[serde(default)]
text_style: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct ConnectorLineArgs {
color: Option<String>,
width: Option<u32>,
style: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PointArgs {
left: u32,
top: u32,
}
#[derive(Debug, Deserialize)]
struct AddConnectorArgs {
slide_index: u32,
connector_type: String,
start: PointArgs,
end: PointArgs,
line: Option<ConnectorLineArgs>,
start_arrow: Option<String>,
end_arrow: Option<String>,
arrow_size: Option<String>,
label: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddImageArgs {
slide_index: u32,
path: Option<PathBuf>,
data_url: Option<String>,
blob: Option<String>,
uri: Option<String>,
position: PositionArgs,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
lock_aspect_ratio: Option<bool>,
alt: Option<String>,
prompt: Option<String>,
}
impl AddImageArgs {
fn image_source(&self) -> Result<ImageInputSource, PresentationArtifactError> {
match (&self.path, &self.data_url, &self.blob, &self.uri) {
(Some(path), None, None, None) => Ok(ImageInputSource::Path(path.clone())),
(None, Some(data_url), None, None) => Ok(ImageInputSource::DataUrl(data_url.clone())),
(None, None, Some(blob), None) => Ok(ImageInputSource::Blob(blob.clone())),
(None, None, None, Some(uri)) => Ok(ImageInputSource::Uri(uri.clone())),
(None, None, None, None) if self.prompt.is_some() => Ok(ImageInputSource::Placeholder),
_ => Err(PresentationArtifactError::InvalidArgs {
action: "add_image".to_string(),
message:
"provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image"
.to_string(),
}),
}
}
}
enum ImageInputSource {
Path(PathBuf),
DataUrl(String),
Blob(String),
Uri(String),
Placeholder,
}
#[derive(Debug, Clone, Deserialize)]
struct ImageCropArgs {
left: f64,
top: f64,
right: f64,
bottom: f64,
}
#[derive(Debug, Deserialize)]
struct AddTableArgs {
slide_index: u32,
position: PositionArgs,
rows: Vec<Vec<Value>>,
column_widths: Option<Vec<u32>>,
row_heights: Option<Vec<u32>>,
style: Option<String>,
style_options: Option<TableStyleOptionsArgs>,
borders: Option<TableBordersArgs>,
right_to_left: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AddChartArgs {
slide_index: u32,
position: PositionArgs,
chart_type: String,
categories: Vec<String>,
series: Vec<ChartSeriesArgs>,
title: Option<String>,
style_index: Option<u32>,
has_legend: Option<bool>,
legend_position: Option<String>,
#[serde(default)]
legend_text_style: TextStylingArgs,
x_axis_title: Option<String>,
y_axis_title: Option<String>,
data_labels: Option<ChartDataLabelsArgs>,
chart_fill: Option<String>,
plot_area_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChartSeriesArgs {
name: String,
values: Vec<f64>,
categories: Option<Vec<String>>,
x_values: Option<Vec<f64>>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
marker: Option<ChartMarkerArgs>,
data_label_overrides: Option<Vec<ChartDataLabelOverrideArgs>>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartMarkerArgs {
symbol: Option<String>,
size: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartDataLabelsArgs {
show_value: Option<bool>,
show_category_name: Option<bool>,
show_leader_lines: Option<bool>,
position: Option<String>,
#[serde(default)]
text_style: TextStylingArgs,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartDataLabelOverrideArgs {
idx: u32,
text: Option<String>,
position: Option<String>,
#[serde(default)]
text_style: TextStylingArgs,
fill: Option<String>,
stroke: Option<StrokeArgs>,
}
#[derive(Debug, Deserialize)]
struct UpdateTextArgs {
element_id: String,
text: String,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
struct SetRichTextArgs {
element_id: Option<String>,
slide_index: Option<u32>,
row: Option<u32>,
column: Option<u32>,
notes: Option<bool>,
text: RichTextInput,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
struct FormatTextRangeArgs {
element_id: Option<String>,
slide_index: Option<u32>,
row: Option<u32>,
column: Option<u32>,
notes: Option<bool>,
query: Option<String>,
occurrence: Option<usize>,
start_cp: Option<usize>,
length: Option<usize>,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
link: Option<RichTextLinkInput>,
spacing_before: Option<u32>,
spacing_after: Option<u32>,
line_spacing: Option<f32>,
}
#[derive(Debug, Deserialize)]
struct ReplaceTextArgs {
element_id: String,
search: String,
replace: String,
}
#[derive(Debug, Deserialize)]
struct InsertTextAfterArgs {
element_id: String,
after: String,
insert: String,
}
#[derive(Debug, Deserialize)]
struct SetHyperlinkArgs {
element_id: String,
link_type: Option<String>,
url: Option<String>,
slide_index: Option<u32>,
address: Option<String>,
subject: Option<String>,
path: Option<String>,
tooltip: Option<String>,
highlight_click: Option<bool>,
clear: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct UpdateShapeStyleArgs {
element_id: String,
position: Option<PartialPositionArgs>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
lock_aspect_ratio: Option<bool>,
z_order: Option<u32>,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
struct ElementIdArgs {
element_id: String,
}
#[derive(Debug, Deserialize)]
struct ReplaceImageArgs {
element_id: String,
path: Option<PathBuf>,
data_url: Option<String>,
blob: Option<String>,
uri: Option<String>,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
lock_aspect_ratio: Option<bool>,
alt: Option<String>,
prompt: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateTableCellArgs {
element_id: String,
row: u32,
column: u32,
value: Value,
#[serde(default)]
styling: TextStylingArgs,
background_fill: Option<String>,
alignment: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableStyleOptionsArgs {
header_row: Option<bool>,
banded_rows: Option<bool>,
banded_columns: Option<bool>,
first_column: Option<bool>,
last_column: Option<bool>,
total_row: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableBorderArgs {
color: String,
width: u32,
}
#[derive(Debug, Clone, Deserialize)]
struct TableBordersArgs {
outside: Option<TableBorderArgs>,
inside: Option<TableBorderArgs>,
top: Option<TableBorderArgs>,
bottom: Option<TableBorderArgs>,
left: Option<TableBorderArgs>,
right: Option<TableBorderArgs>,
}
#[derive(Debug, Deserialize)]
struct UpdateTableStyleArgs {
element_id: String,
style: Option<String>,
style_options: Option<TableStyleOptionsArgs>,
borders: Option<TableBordersArgs>,
right_to_left: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct StyleTableBlockArgs {
element_id: String,
row: u32,
column: u32,
row_count: u32,
column_count: u32,
#[serde(default)]
styling: TextStylingArgs,
background_fill: Option<String>,
alignment: Option<String>,
borders: Option<TableBordersArgs>,
}
#[derive(Debug, Deserialize)]
struct MergeTableCellsArgs {
element_id: String,
start_row: u32,
end_row: u32,
start_column: u32,
end_column: u32,
}
#[derive(Debug, Deserialize)]
struct UpdateChartArgs {
element_id: String,
title: Option<String>,
categories: Option<Vec<String>>,
style_index: Option<u32>,
has_legend: Option<bool>,
legend_position: Option<String>,
#[serde(default)]
legend_text_style: TextStylingArgs,
x_axis_title: Option<String>,
y_axis_title: Option<String>,
data_labels: Option<ChartDataLabelsArgs>,
chart_fill: Option<String>,
plot_area_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddChartSeriesArgs {
element_id: String,
name: String,
values: Vec<f64>,
categories: Option<Vec<String>>,
x_values: Option<Vec<f64>>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
marker: Option<ChartMarkerArgs>,
}
#[derive(Debug, Deserialize)]
struct SetCommentAuthorArgs {
display_name: String,
initials: String,
email: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct CommentPositionArgs {
x: u32,
y: u32,
}
#[derive(Debug, Deserialize)]
struct AddCommentThreadArgs {
slide_index: Option<u32>,
element_id: Option<String>,
query: Option<String>,
occurrence: Option<usize>,
start_cp: Option<usize>,
length: Option<usize>,
text: String,
position: Option<CommentPositionArgs>,
}
#[derive(Debug, Deserialize)]
struct AddCommentReplyArgs {
thread_id: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct ToggleCommentReactionArgs {
thread_id: String,
message_id: Option<String>,
emoji: String,
}
#[derive(Debug, Deserialize)]
struct CommentThreadIdArgs {
thread_id: String,
}

View File

@@ -0,0 +1,871 @@
fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> String {
let include_kinds = args
.include
.as_deref()
.or(args.kind.as_deref())
.unwrap_or(
"deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList,textRange,comment",
);
let included_kinds = include_kinds
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect::<HashSet<_>>();
let excluded_kinds = args
.exclude
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect::<HashSet<_>>();
let include = |name: &str| included_kinds.contains(name) && !excluded_kinds.contains(name);
let mut records: Vec<(Value, Option<String>)> = Vec::new();
if include("deck") {
records.push((
serde_json::json!({
"kind": "deck",
"id": format!("pr/{}", document.artifact_id),
"name": document.name,
"slides": document.slides.len(),
"styleIds": document
.named_text_styles()
.iter()
.map(|style| format!("st/{}", style.name))
.collect::<Vec<_>>(),
"activeSlideIndex": document.active_slide_index,
"activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)),
"commentThreadIds": document
.comment_threads
.iter()
.map(|thread| format!("th/{}", thread.thread_id))
.collect::<Vec<_>>(),
}),
None,
));
}
if include("styleList") {
for style in document.named_text_styles() {
records.push((named_text_style_to_json(&style, "st"), None));
}
}
if include("layoutList") {
for layout in &document.layouts {
let placeholders = resolved_layout_placeholders(document, &layout.layout_id, "inspect")
.unwrap_or_default()
.into_iter()
.map(|placeholder| {
serde_json::json!({
"name": placeholder.definition.name,
"type": placeholder.definition.placeholder_type,
"sourceLayoutId": placeholder.source_layout_id,
"textPreview": placeholder.definition.text,
})
})
.collect::<Vec<_>>();
records.push((
serde_json::json!({
"kind": "layout",
"id": format!("ly/{}", layout.layout_id),
"layoutId": layout.layout_id,
"name": layout.name,
"type": match layout.kind { LayoutKind::Layout => "layout", LayoutKind::Master => "master" },
"parentLayoutId": layout.parent_layout_id,
"placeholders": placeholders,
}),
None,
));
}
}
for (index, slide) in document.slides.iter().enumerate() {
let slide_id = format!("sl/{}", slide.slide_id);
if include("slide") {
records.push((
serde_json::json!({
"kind": "slide",
"id": slide_id,
"slide": index + 1,
"slideIndex": index,
"isActive": document.active_slide_index == Some(index),
"layoutId": slide.layout_id,
"elements": slide.elements.len(),
}),
Some(slide_id.clone()),
));
}
if include("notes") && !slide.notes.text.is_empty() {
records.push((
serde_json::json!({
"kind": "notes",
"id": format!("nt/{}", slide.slide_id),
"slide": index + 1,
"visible": slide.notes.visible,
"text": slide.notes.text,
"textPreview": slide.notes.text.replace('\n', " | "),
"textChars": slide.notes.text.chars().count(),
"textLines": slide.notes.text.lines().count(),
"richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text),
}),
Some(slide_id.clone()),
));
}
if include("textRange") {
records.extend(
slide
.notes
.rich_text
.ranges
.iter()
.map(|range| {
let mut record = text_range_to_proto(&slide.notes.text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!("nt/{}", slide.slide_id));
record["hostKind"] = Value::String("notes".to_string());
(record, Some(slide_id.clone()))
}),
);
}
for element in &slide.elements {
let mut record = match element {
PresentationElement::Text(text) => {
if !include("textbox") {
continue;
}
serde_json::json!({
"kind": "textbox",
"id": format!("sh/{}", text.element_id),
"slide": index + 1,
"text": text.text,
"textStyle": text_style_to_proto(&text.style),
"textPreview": text.text.replace('\n', " | "),
"textChars": text.text.chars().count(),
"textLines": text.text.lines().count(),
"richText": rich_text_to_proto(&text.text, &text.rich_text),
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Shape(shape) => {
if !(include("shape") || include("textbox") && shape.text.is_some()) {
continue;
}
let kind = if shape.text.is_some() && include("textbox") {
"textbox"
} else {
"shape"
};
let mut record = serde_json::json!({
"kind": kind,
"id": format!("sh/{}", shape.element_id),
"slide": index + 1,
"geometry": format!("{:?}", shape.geometry),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"richText": shape
.text
.as_ref()
.zip(shape.rich_text.as_ref())
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
.unwrap_or(Value::Null),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height],
"bboxUnit": "points",
});
if let Some(text) = &shape.text {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
record
}
PresentationElement::Connector(connector) => {
if !include("shape") && !include("connector") {
continue;
}
serde_json::json!({
"kind": "connector",
"id": format!("cn/{}", connector.element_id),
"slide": index + 1,
"connectorType": format!("{:?}", connector.connector_type),
"start": [connector.start.left, connector.start.top],
"end": [connector.end.left, connector.end.top],
"lineStyle": format!("{:?}", connector.line_style),
"label": connector.label,
})
}
PresentationElement::Table(table) => {
if !include("table") {
continue;
}
serde_json::json!({
"kind": "table",
"id": format!("tb/{}", table.element_id),
"slide": index + 1,
"rows": table.rows.len(),
"cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"preview": table.rows.first().map(|row| row.iter().map(|cell| cell.text.clone()).collect::<Vec<_>>().join(" | ")),
"style": table.style,
"styleOptions": table_style_options_to_proto(&table.style_options),
"borders": table.borders.as_ref().map(table_borders_to_proto),
"rightToLeft": table.right_to_left,
"cellTextStyles": table
.rows
.iter()
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"rowsData": table
.rows
.iter()
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Chart(chart) => {
if !include("chart") {
continue;
}
serde_json::json!({
"kind": "chart",
"id": format!("ch/{}", chart.element_id),
"slide": index + 1,
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"styleIndex": chart.style_index,
"hasLegend": chart.has_legend,
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
"chartFill": chart.chart_fill,
"plotAreaFill": chart.plot_area_fill,
"series": chart
.series
.iter()
.map(|series| serde_json::json!({
"name": series.name,
"values": series.values,
"categories": series.categories,
"xValues": series.x_values,
"fill": series.fill,
"stroke": series.stroke.as_ref().map(stroke_to_proto),
"marker": series.marker.as_ref().map(chart_marker_to_proto),
"dataLabelOverrides": series
.data_label_overrides
.iter()
.map(chart_data_label_override_to_proto)
.collect::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Image(image) => {
if !include("image") {
continue;
}
serde_json::json!({
"kind": "image",
"id": format!("im/{}", image.element_id),
"slide": index + 1,
"alt": image.alt_text,
"prompt": image.prompt,
"fit": format!("{:?}", image.fit_mode),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"isPlaceholder": image.is_placeholder,
"lockAspectRatio": image.lock_aspect_ratio,
"bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height],
"bboxUnit": "points",
})
}
};
if let Some(placeholder) = match element {
PresentationElement::Text(text) => text.placeholder.as_ref(),
PresentationElement::Shape(shape) => shape.placeholder.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
PresentationElement::Image(image) => image.placeholder.as_ref(),
} {
record["placeholder"] = Value::String(placeholder.placeholder_type.clone());
record["placeholderName"] = Value::String(placeholder.name.clone());
record["placeholderIndex"] =
placeholder.index.map(Value::from).unwrap_or(Value::Null);
}
if let PresentationElement::Shape(shape) = element
&& let Some(stroke) = &shape.stroke
{
record["stroke"] = serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
});
}
if let Some(hyperlink) = match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["hyperlink"] = hyperlink.to_json();
}
records.push((record, Some(slide_id.clone())));
if include("textRange") {
match element {
PresentationElement::Text(text) => {
records.extend(text.rich_text.ranges.iter().map(|range| {
let mut record = text_range_to_proto(&text.text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!("sh/{}", text.element_id));
record["hostKind"] = Value::String("textbox".to_string());
(record, Some(slide_id.clone()))
}));
}
PresentationElement::Shape(shape) => {
if let Some((text, rich_text)) = shape.text.as_ref().zip(shape.rich_text.as_ref()) {
records.extend(rich_text.ranges.iter().map(|range| {
let mut record = text_range_to_proto(text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!("sh/{}", shape.element_id));
record["hostKind"] = Value::String("textbox".to_string());
(record, Some(slide_id.clone()))
}));
}
}
PresentationElement::Table(table) => {
for (row_index, row) in table.rows.iter().enumerate() {
for (column_index, cell) in row.iter().enumerate() {
records.extend(cell.rich_text.ranges.iter().map(|range| {
let mut record = text_range_to_proto(&cell.text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!(
"tb/{}#cell/{row_index}/{column_index}",
table.element_id
));
record["hostKind"] = Value::String("tableCell".to_string());
(record, Some(slide_id.clone()))
}));
}
}
}
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Chart(_) => {}
}
}
}
}
if include("comment") {
records.extend(document.comment_threads.iter().map(|thread| {
let mut record = comment_thread_to_proto(thread);
record["id"] = Value::String(format!("th/{}", thread.thread_id));
(record, None)
}));
}
if let Some(target_id) = args.target_id.as_deref() {
records.retain(|(record, slide_id)| {
legacy_target_matches(target_id, record, slide_id.as_deref())
});
if records.is_empty() {
records.push((
serde_json::json!({
"kind": "notice",
"noticeType": "targetNotFound",
"target": { "id": target_id },
"message": format!("No inspect records matched target `{target_id}`."),
}),
None,
));
}
}
if let Some(search) = args.search.as_deref() {
let search_lowercase = search.to_ascii_lowercase();
records.retain(|(record, _)| {
record
.to_string()
.to_ascii_lowercase()
.contains(&search_lowercase)
});
if records.is_empty() {
records.push((
serde_json::json!({
"kind": "notice",
"noticeType": "noMatches",
"search": search,
"message": format!("No inspect records matched search `{search}`."),
}),
None,
));
}
}
if let Some(target) = args.target.as_ref() {
if let Some(target_index) = records.iter().position(|(record, _)| {
record.get("id").and_then(Value::as_str) == Some(target.id.as_str())
}) {
let start = target_index.saturating_sub(target.before_lines.unwrap_or(0));
let end = (target_index + target.after_lines.unwrap_or(0) + 1).min(records.len());
records = records.into_iter().skip(start).take(end - start).collect();
} else {
records = vec![(
serde_json::json!({
"kind": "notice",
"noticeType": "targetNotFound",
"target": {
"id": target.id,
"beforeLines": target.before_lines,
"afterLines": target.after_lines,
},
"message": format!("No inspect records matched target `{}`.", target.id),
}),
None,
)];
}
}
let mut lines = Vec::new();
let mut omitted_lines = 0usize;
let mut omitted_chars = 0usize;
for line in records.into_iter().map(|(record, _)| record.to_string()) {
let separator_len = usize::from(!lines.is_empty());
if let Some(max_chars) = args.max_chars
&& lines.iter().map(String::len).sum::<usize>() + separator_len + line.len() > max_chars
{
omitted_lines += 1;
omitted_chars += line.len();
continue;
}
lines.push(line);
}
if omitted_lines > 0 {
lines.push(
serde_json::json!({
"kind": "notice",
"noticeType": "truncation",
"maxChars": args.max_chars,
"omittedLines": omitted_lines,
"omittedChars": omitted_chars,
"message": format!(
"Truncated inspect output by omitting {omitted_lines} lines. Increase maxChars or narrow the filter."
),
})
.to_string(),
);
}
lines.join("\n")
}
fn legacy_target_matches(target_id: &str, record: &Value, slide_id: Option<&str>) -> bool {
record.get("id").and_then(Value::as_str) == Some(target_id) || slide_id == Some(target_id)
}
fn add_text_metadata(record: &mut Value, text: &str) {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
fn normalize_element_lookup_id(element_id: &str) -> &str {
element_id
.split_once('/')
.map(|(_, normalized)| normalized)
.unwrap_or(element_id)
}
fn resolve_anchor(
document: &PresentationDocument,
id: &str,
action: &str,
) -> Result<Value, PresentationArtifactError> {
if id == format!("pr/{}", document.artifact_id) {
return Ok(serde_json::json!({
"kind": "deck",
"id": id,
"artifactId": document.artifact_id,
"name": document.name,
"slideCount": document.slides.len(),
"styleIds": document
.named_text_styles()
.iter()
.map(|style| format!("st/{}", style.name))
.collect::<Vec<_>>(),
"activeSlideIndex": document.active_slide_index,
"activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)),
}));
}
if let Some(style_name) = id.strip_prefix("st/") {
let named_style = document
.named_text_styles()
.into_iter()
.find(|style| style.name == style_name)
.ok_or_else(|| PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("unknown style id `{id}`"),
})?;
return Ok(named_text_style_to_json(&named_style, "st"));
}
for (slide_index, slide) in document.slides.iter().enumerate() {
let slide_id = format!("sl/{}", slide.slide_id);
if id == slide_id {
return Ok(serde_json::json!({
"kind": "slide",
"id": slide_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"isActive": document.active_slide_index == Some(slide_index),
"layoutId": slide.layout_id,
"notesId": (!slide.notes.text.is_empty()).then(|| format!("nt/{}", slide.slide_id)),
"elementIds": slide.elements.iter().map(|element| {
let prefix = match element {
PresentationElement::Text(_) | PresentationElement::Shape(_) => "sh",
PresentationElement::Connector(_) => "cn",
PresentationElement::Image(_) => "im",
PresentationElement::Table(_) => "tb",
PresentationElement::Chart(_) => "ch",
};
format!("{prefix}/{}", element.element_id())
}).collect::<Vec<_>>(),
}));
}
let notes_id = format!("nt/{}", slide.slide_id);
if id == notes_id {
let mut record = serde_json::json!({
"kind": "notes",
"id": notes_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"visible": slide.notes.visible,
"text": slide.notes.text,
});
add_text_metadata(&mut record, &slide.notes.text);
record["richText"] = rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text);
return Ok(record);
}
if let Some(range_id) = id.strip_prefix("tr/")
&& let Some(record) = slide
.notes
.rich_text
.ranges
.iter()
.find(|range| range.range_id == range_id)
.map(|range| {
let mut record = text_range_to_proto(&slide.notes.text, range);
record["kind"] = Value::String("textRange".to_string());
record["id"] = Value::String(id.to_string());
record["slide"] = Value::from(slide_index + 1);
record["slideIndex"] = Value::from(slide_index);
record["hostAnchor"] = Value::String(notes_id.clone());
record["hostKind"] = Value::String("notes".to_string());
record
})
{
return Ok(record);
}
for element in &slide.elements {
let mut record = match element {
PresentationElement::Text(text) => {
let mut record = serde_json::json!({
"kind": "textbox",
"id": format!("sh/{}", text.element_id),
"elementId": text.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"text": text.text,
"textStyle": text_style_to_proto(&text.style),
"richText": rich_text_to_proto(&text.text, &text.rich_text),
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
"bboxUnit": "points",
});
add_text_metadata(&mut record, &text.text);
record
}
PresentationElement::Shape(shape) => {
let mut record = serde_json::json!({
"kind": if shape.text.is_some() { "textbox" } else { "shape" },
"id": format!("sh/{}", shape.element_id),
"elementId": shape.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"geometry": format!("{:?}", shape.geometry),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"richText": shape
.text
.as_ref()
.zip(shape.rich_text.as_ref())
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
.unwrap_or(Value::Null),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height],
"bboxUnit": "points",
});
if let Some(text) = &shape.text {
add_text_metadata(&mut record, text);
}
record
}
PresentationElement::Connector(connector) => serde_json::json!({
"kind": "connector",
"id": format!("cn/{}", connector.element_id),
"elementId": connector.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"connectorType": format!("{:?}", connector.connector_type),
"start": [connector.start.left, connector.start.top],
"end": [connector.end.left, connector.end.top],
"lineStyle": format!("{:?}", connector.line_style),
"label": connector.label,
}),
PresentationElement::Image(image) => serde_json::json!({
"kind": "image",
"id": format!("im/{}", image.element_id),
"elementId": image.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"alt": image.alt_text,
"prompt": image.prompt,
"fit": format!("{:?}", image.fit_mode),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"isPlaceholder": image.is_placeholder,
"lockAspectRatio": image.lock_aspect_ratio,
"bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height],
"bboxUnit": "points",
}),
PresentationElement::Table(table) => serde_json::json!({
"kind": "table",
"id": format!("tb/{}", table.element_id),
"elementId": table.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"rows": table.rows.len(),
"cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"style": table.style,
"styleOptions": table_style_options_to_proto(&table.style_options),
"borders": table.borders.as_ref().map(table_borders_to_proto),
"rightToLeft": table.right_to_left,
"cellTextStyles": table
.rows
.iter()
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"rowsData": table
.rows
.iter()
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
"bboxUnit": "points",
}),
PresentationElement::Chart(chart) => serde_json::json!({
"kind": "chart",
"id": format!("ch/{}", chart.element_id),
"elementId": chart.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"styleIndex": chart.style_index,
"hasLegend": chart.has_legend,
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
"chartFill": chart.chart_fill,
"plotAreaFill": chart.plot_area_fill,
"series": chart
.series
.iter()
.map(|series| serde_json::json!({
"name": series.name,
"values": series.values,
"categories": series.categories,
"xValues": series.x_values,
"fill": series.fill,
"stroke": series.stroke.as_ref().map(stroke_to_proto),
"marker": series.marker.as_ref().map(chart_marker_to_proto),
"dataLabelOverrides": series
.data_label_overrides
.iter()
.map(chart_data_label_override_to_proto)
.collect::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
"bboxUnit": "points",
}),
};
if let Some(hyperlink) = match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["hyperlink"] = hyperlink.to_json();
}
if let PresentationElement::Shape(shape) = element
&& let Some(stroke) = &shape.stroke
{
record["stroke"] = serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
});
}
if let Some(placeholder) = match element {
PresentationElement::Text(text) => text.placeholder.as_ref(),
PresentationElement::Shape(shape) => shape.placeholder.as_ref(),
PresentationElement::Image(image) => image.placeholder.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["placeholder"] = Value::String(placeholder.placeholder_type.clone());
record["placeholderName"] = Value::String(placeholder.name.clone());
record["placeholderIndex"] =
placeholder.index.map(Value::from).unwrap_or(Value::Null);
}
if record.get("id").and_then(Value::as_str) == Some(id) {
return Ok(record);
}
if let Some(range_id) = id.strip_prefix("tr/") {
match element {
PresentationElement::Text(text) => {
if let Some(range) =
text.rich_text.ranges.iter().find(|range| range.range_id == range_id)
{
let mut range_record = text_range_to_proto(&text.text, range);
range_record["kind"] = Value::String("textRange".to_string());
range_record["id"] = Value::String(id.to_string());
range_record["slide"] = Value::from(slide_index + 1);
range_record["slideIndex"] = Value::from(slide_index);
range_record["hostAnchor"] =
Value::String(format!("sh/{}", text.element_id));
range_record["hostKind"] = Value::String("textbox".to_string());
return Ok(range_record);
}
}
PresentationElement::Shape(shape) => {
if let Some((text, rich_text)) =
shape.text.as_ref().zip(shape.rich_text.as_ref())
&& let Some(range) =
rich_text.ranges.iter().find(|range| range.range_id == range_id)
{
let mut range_record = text_range_to_proto(text, range);
range_record["kind"] = Value::String("textRange".to_string());
range_record["id"] = Value::String(id.to_string());
range_record["slide"] = Value::from(slide_index + 1);
range_record["slideIndex"] = Value::from(slide_index);
range_record["hostAnchor"] =
Value::String(format!("sh/{}", shape.element_id));
range_record["hostKind"] = Value::String("textbox".to_string());
return Ok(range_record);
}
}
PresentationElement::Table(table) => {
for (row_index, row) in table.rows.iter().enumerate() {
for (column_index, cell) in row.iter().enumerate() {
if let Some(range) = cell
.rich_text
.ranges
.iter()
.find(|range| range.range_id == range_id)
{
let mut range_record = text_range_to_proto(&cell.text, range);
range_record["kind"] = Value::String("textRange".to_string());
range_record["id"] = Value::String(id.to_string());
range_record["slide"] = Value::from(slide_index + 1);
range_record["slideIndex"] = Value::from(slide_index);
range_record["hostAnchor"] = Value::String(format!(
"tb/{}#cell/{row_index}/{column_index}",
table.element_id
));
range_record["hostKind"] =
Value::String("tableCell".to_string());
return Ok(range_record);
}
}
}
}
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Chart(_) => {}
}
}
}
}
if let Some(thread_id) = id.strip_prefix("th/")
&& let Some(thread) = document
.comment_threads
.iter()
.find(|thread| thread.thread_id == thread_id)
{
let mut record = comment_thread_to_proto(thread);
record["id"] = Value::String(id.to_string());
return Ok(record);
}
for layout in &document.layouts {
let layout_id = format!("ly/{}", layout.layout_id);
if id == layout_id {
return Ok(serde_json::json!({
"kind": "layout",
"id": layout_id,
"layoutId": layout.layout_id,
"name": layout.name,
"type": match layout.kind {
LayoutKind::Layout => "layout",
LayoutKind::Master => "master",
},
"parentLayoutId": layout.parent_layout_id,
"placeholders": layout_placeholder_list(document, &layout.layout_id, action)?,
}));
}
}
Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("unknown resolve id `{id}`"),
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
include!("api.rs");
include!("manager.rs");
include!("response.rs");
include!("model.rs");
include!("args.rs");
include!("parsing.rs");
include!("proto.rs");
include!("inspect.rs");
include!("pptx.rs");
include!("snapshot.rs");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,951 @@
const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json";
fn import_codex_metadata_document(path: &Path) -> Result<Option<PresentationDocument>, String> {
let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
let mut archive = ZipArchive::new(file).map_err(|error| error.to_string())?;
let mut entry = match archive.by_name(CODEX_METADATA_ENTRY) {
Ok(entry) => entry,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(error) => return Err(error.to_string()),
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)
.map_err(|error| error.to_string())?;
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|error| error.to_string())
}
fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result<Vec<u8>, String> {
let bytes = document
.to_ppt_rs()
.build()
.map_err(|error| format!("{action}: {error}"))?;
patch_pptx_package(bytes, document).map_err(|error| format!("{action}: {error}"))
}
struct SlideImageAsset {
xml: String,
relationship_xml: String,
media_path: String,
media_bytes: Vec<u8>,
extension: String,
}
fn normalized_image_extension(format: &str) -> String {
match format.to_ascii_lowercase().as_str() {
"jpeg" => "jpg".to_string(),
other => other.to_string(),
}
}
fn image_relationship_xml(relationship_id: &str, target: &str) -> String {
format!(
r#"<Relationship Id="{relationship_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{}"/>"#,
ppt_rs::escape_xml(target)
)
}
fn image_picture_xml(
image: &ImageElement,
shape_id: usize,
relationship_id: &str,
frame: Rect,
crop: Option<ImageCrop>,
) -> String {
let blip_fill = if let Some((crop_left, crop_top, crop_right, crop_bottom)) = crop {
format!(
r#"<p:blipFill>
<a:blip r:embed="{relationship_id}"/>
<a:srcRect l="{}" t="{}" r="{}" b="{}"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>"#,
(crop_left * 100_000.0).round() as u32,
(crop_top * 100_000.0).round() as u32,
(crop_right * 100_000.0).round() as u32,
(crop_bottom * 100_000.0).round() as u32,
)
} else {
format!(
r#"<p:blipFill>
<a:blip r:embed="{relationship_id}"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>"#
)
};
let descr = image
.alt_text
.as_deref()
.map(|alt| format!(r#" descr="{}""#, ppt_rs::escape_xml(alt)))
.unwrap_or_default();
let no_change_aspect = if image.lock_aspect_ratio { 1 } else { 0 };
let rotation = image
.rotation_degrees
.map(|rotation| format!(r#" rot="{}""#, i64::from(rotation) * 60_000))
.unwrap_or_default();
let flip_horizontal = if image.flip_horizontal {
r#" flipH="1""#
} else {
""
};
let flip_vertical = if image.flip_vertical {
r#" flipV="1""#
} else {
""
};
format!(
r#"<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="Picture {shape_id}"{descr}/>
<p:cNvPicPr>
<a:picLocks noChangeAspect="{no_change_aspect}"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
{blip_fill}
<p:spPr>
<a:xfrm{rotation}{flip_horizontal}{flip_vertical}>
<a:off x="{}" y="{}"/>
<a:ext cx="{}" cy="{}"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>"#,
points_to_emu(frame.left),
points_to_emu(frame.top),
points_to_emu(frame.width),
points_to_emu(frame.height),
)
}
fn slide_image_assets(
slide: &PresentationSlide,
next_media_index: &mut usize,
) -> Vec<SlideImageAsset> {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let shape_count = ordered
.iter()
.filter(|element| {
matches!(
element,
PresentationElement::Text(_)
| PresentationElement::Shape(_)
| PresentationElement::Image(ImageElement { payload: None, .. })
)
})
.count()
+ usize::from(slide.background_fill.is_some());
let mut image_index = 0_usize;
let mut assets = Vec::new();
for element in ordered {
let PresentationElement::Image(image) = element else {
continue;
};
let Some(payload) = &image.payload else {
continue;
};
let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch {
fit_image(image)
} else {
(
image.frame.left,
image.frame.top,
image.frame.width,
image.frame.height,
None,
)
};
image_index += 1;
let relationship_id = format!("rIdImage{image_index}");
let extension = normalized_image_extension(&payload.format);
let media_name = format!("image{next_media_index}.{extension}");
*next_media_index += 1;
assets.push(SlideImageAsset {
xml: image_picture_xml(
image,
20 + shape_count + image_index - 1,
&relationship_id,
Rect {
left,
top,
width,
height,
},
image.crop.or(fitted_crop),
),
relationship_xml: image_relationship_xml(
&relationship_id,
&format!("../media/{media_name}"),
),
media_path: format!("ppt/media/{media_name}"),
media_bytes: payload.bytes.clone(),
extension,
});
}
assets
}
fn patch_pptx_package(
source_bytes: Vec<u8>,
document: &PresentationDocument,
) -> Result<Vec<u8>, String> {
let mut archive =
ZipArchive::new(Cursor::new(source_bytes)).map_err(|error| error.to_string())?;
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
let mut next_media_index = 1_usize;
let mut pending_slide_relationships = HashMap::new();
let mut pending_slide_images = HashMap::new();
let mut pending_media = Vec::new();
let mut image_extensions = BTreeSet::new();
for (slide_index, slide) in document.slides.iter().enumerate() {
let slide_number = slide_index + 1;
let images = slide_image_assets(slide, &mut next_media_index);
let mut relationships = slide_hyperlink_relationships(slide);
relationships.extend(images.iter().map(|image| image.relationship_xml.clone()));
if !relationships.is_empty() {
pending_slide_relationships.insert(slide_number, relationships);
}
if !images.is_empty() {
image_extensions.extend(images.iter().map(|image| image.extension.clone()));
pending_media.extend(
images
.iter()
.map(|image| (image.media_path.clone(), image.media_bytes.clone())),
);
pending_slide_images.insert(slide_number, images);
}
}
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| error.to_string())?;
if file.is_dir() {
continue;
}
let name = file.name().to_string();
if name == CODEX_METADATA_ENTRY {
continue;
}
let options = file.options();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|error| error.to_string())?;
writer
.start_file(&name, options)
.map_err(|error| error.to_string())?;
if name == "[Content_Types].xml" {
writer
.write_all(update_content_types_xml(bytes, &image_extensions)?.as_bytes())
.map_err(|error| error.to_string())?;
continue;
}
if name == "ppt/presentation.xml" {
writer
.write_all(
update_presentation_xml_dimensions(bytes, document.slide_size)?.as_bytes(),
)
.map_err(|error| error.to_string())?;
continue;
}
if let Some(slide_number) = parse_slide_xml_path(&name) {
writer
.write_all(
update_slide_xml(
bytes,
&document.slides[slide_number - 1],
pending_slide_images
.get(&slide_number)
.map(std::vec::Vec::as_slice)
.unwrap_or(&[]),
)?
.as_bytes(),
)
.map_err(|error| error.to_string())?;
continue;
}
if let Some(slide_number) = parse_slide_relationships_path(&name)
&& let Some(relationships) = pending_slide_relationships.remove(&slide_number)
{
writer
.write_all(update_slide_relationships_xml(bytes, &relationships)?.as_bytes())
.map_err(|error| error.to_string())?;
continue;
}
writer
.write_all(&bytes)
.map_err(|error| error.to_string())?;
}
for (slide_number, relationships) in pending_slide_relationships {
writer
.start_file(
format!("ppt/slides/_rels/slide{slide_number}.xml.rels"),
SimpleFileOptions::default(),
)
.map_err(|error| error.to_string())?;
writer
.write_all(slide_relationships_xml(&relationships).as_bytes())
.map_err(|error| error.to_string())?;
}
for (path, bytes) in pending_media {
writer
.start_file(path, SimpleFileOptions::default())
.map_err(|error| error.to_string())?;
writer
.write_all(&bytes)
.map_err(|error| error.to_string())?;
}
writer
.start_file(CODEX_METADATA_ENTRY, SimpleFileOptions::default())
.map_err(|error| error.to_string())?;
writer
.write_all(
&serde_json::to_vec(document).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
writer
.finish()
.map_err(|error| error.to_string())
.map(Cursor::into_inner)
}
fn update_presentation_xml_dimensions(
existing_bytes: Vec<u8>,
slide_size: Rect,
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let updated = replace_self_closing_xml_tag(
&existing,
"p:sldSz",
&format!(
r#"<p:sldSz cx="{}" cy="{}" type="screen4x3"/>"#,
points_to_emu(slide_size.width),
points_to_emu(slide_size.height)
),
)?;
replace_self_closing_xml_tag(
&updated,
"p:notesSz",
&format!(
r#"<p:notesSz cx="{}" cy="{}"/>"#,
points_to_emu(slide_size.height),
points_to_emu(slide_size.width)
),
)
}
fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result<String, String> {
let start = xml
.find(&format!("<{tag} "))
.ok_or_else(|| format!("presentation xml is missing `<{tag} .../>`"))?;
let end = xml[start..]
.find("/>")
.map(|offset| start + offset + 2)
.ok_or_else(|| format!("presentation xml tag `{tag}` is not self-closing"))?;
Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..]))
}
fn slide_hyperlink_relationships(slide: &PresentationSlide) -> Vec<String> {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let mut hyperlink_index = 1_u32;
let mut relationships = Vec::new();
for element in ordered {
let Some(hyperlink) = (match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
}) else {
continue;
};
let relationship_id = format!("rIdHyperlink{hyperlink_index}");
hyperlink_index += 1;
relationships.push(hyperlink.relationship_xml(&relationship_id));
}
relationships
}
fn parse_slide_relationships_path(path: &str) -> Option<usize> {
path.strip_prefix("ppt/slides/_rels/slide")?
.strip_suffix(".xml.rels")?
.parse::<usize>()
.ok()
}
fn parse_slide_xml_path(path: &str) -> Option<usize> {
path.strip_prefix("ppt/slides/slide")?
.strip_suffix(".xml")?
.parse::<usize>()
.ok()
}
fn update_slide_relationships_xml(
existing_bytes: Vec<u8>,
relationships: &[String],
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let injected = relationships.join("\n");
existing
.contains("</Relationships>")
.then(|| existing.replace("</Relationships>", &format!("{injected}\n</Relationships>")))
.ok_or_else(|| {
"slide relationships xml is missing a closing `</Relationships>`".to_string()
})
}
fn slide_relationships_xml(relationships: &[String]) -> String {
let body = relationships.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
{body}
</Relationships>"#
)
}
fn update_content_types_xml(
existing_bytes: Vec<u8>,
image_extensions: &BTreeSet<String>,
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
if image_extensions.is_empty() {
return Ok(existing);
}
let existing_lower = existing.to_ascii_lowercase();
let additions = image_extensions
.iter()
.filter(|extension| {
!existing_lower.contains(&format!(
r#"extension="{}""#,
extension.to_ascii_lowercase()
))
})
.map(|extension| generate_image_content_type(extension))
.collect::<Vec<_>>();
if additions.is_empty() {
return Ok(existing);
}
existing
.contains("</Types>")
.then(|| existing.replace("</Types>", &format!("{}\n</Types>", additions.join("\n"))))
.ok_or_else(|| "content types xml is missing a closing `</Types>`".to_string())
}
fn update_slide_xml(
existing_bytes: Vec<u8>,
slide: &PresentationSlide,
slide_images: &[SlideImageAsset],
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let existing = replace_image_placeholders(existing, slide_images)?;
let existing = apply_shape_block_patches(existing, slide)?;
let table_xml = slide_table_xml(slide);
if table_xml.is_empty() {
return Ok(existing);
}
existing
.contains("</p:spTree>")
.then(|| existing.replace("</p:spTree>", &format!("{table_xml}\n</p:spTree>")))
.ok_or_else(|| "slide xml is missing a closing `</p:spTree>`".to_string())
}
fn replace_image_placeholders(
existing: String,
slide_images: &[SlideImageAsset],
) -> Result<String, String> {
if slide_images.is_empty() {
return Ok(existing);
}
let mut updated = String::with_capacity(existing.len());
let mut remaining = existing.as_str();
for image in slide_images {
let marker = remaining
.find("name=\"Image Placeholder: ")
.ok_or_else(|| {
"slide xml is missing an image placeholder block for exported images".to_string()
})?;
let start = remaining[..marker].rfind("<p:sp>").ok_or_else(|| {
"slide xml is missing an opening `<p:sp>` for image placeholder".to_string()
})?;
let end = remaining[marker..]
.find("</p:sp>")
.map(|offset| marker + offset + "</p:sp>".len())
.ok_or_else(|| {
"slide xml is missing a closing `</p:sp>` for image placeholder".to_string()
})?;
updated.push_str(&remaining[..start]);
updated.push_str(&image.xml);
remaining = &remaining[end..];
}
updated.push_str(remaining);
Ok(updated)
}
#[derive(Clone, Copy)]
struct ShapeXmlPatch {
line_style: Option<LineStyle>,
flip_horizontal: bool,
flip_vertical: bool,
}
fn apply_shape_block_patches(
existing: String,
slide: &PresentationSlide,
) -> Result<String, String> {
let mut patches = Vec::new();
if slide.background_fill.is_some() {
patches.push(None);
}
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
for element in ordered {
match element {
PresentationElement::Text(_) => patches.push(None),
PresentationElement::Shape(shape) => patches.push(Some(ShapeXmlPatch {
line_style: shape
.stroke
.as_ref()
.map(|stroke| stroke.style)
.filter(|style| *style != LineStyle::Solid),
flip_horizontal: shape.flip_horizontal,
flip_vertical: shape.flip_vertical,
})),
PresentationElement::Image(ImageElement { payload: None, .. }) => patches.push(None),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => {}
}
}
if patches.iter().all(|patch| {
patch.is_none_or(|patch| {
patch.line_style.is_none() && !patch.flip_horizontal && !patch.flip_vertical
})
}) {
return Ok(existing);
}
let mut updated = String::with_capacity(existing.len());
let mut remaining = existing.as_str();
for patch in patches {
let Some(start) = remaining.find("<p:sp>") else {
return Err("slide xml is missing an expected `<p:sp>` block".to_string());
};
let end = remaining[start..]
.find("</p:sp>")
.map(|offset| start + offset + "</p:sp>".len())
.ok_or_else(|| "slide xml is missing a closing `</p:sp>` block".to_string())?;
updated.push_str(&remaining[..start]);
let block = &remaining[start..end];
if let Some(patch) = patch {
updated.push_str(&patch_shape_block(block, patch)?);
} else {
updated.push_str(block);
}
remaining = &remaining[end..];
}
updated.push_str(remaining);
Ok(updated)
}
fn patch_shape_block(block: &str, patch: ShapeXmlPatch) -> Result<String, String> {
let block = if let Some(line_style) = patch.line_style {
patch_shape_block_dash(block, line_style)?
} else {
block.to_string()
};
if patch.flip_horizontal || patch.flip_vertical {
patch_shape_block_flip(&block, patch.flip_horizontal, patch.flip_vertical)
} else {
Ok(block)
}
}
fn patch_shape_block_dash(block: &str, line_style: LineStyle) -> Result<String, String> {
let Some(line_start) = block.find("<a:ln") else {
return Err("shape block is missing an `<a:ln>` entry for stroke styling".to_string());
};
if let Some(dash_start) = block[line_start..].find("<a:prstDash") {
let dash_start = line_start + dash_start;
let dash_end = block[dash_start..]
.find("/>")
.map(|offset| dash_start + offset + 2)
.ok_or_else(|| "shape line dash entry is missing a closing `/>`".to_string())?;
let mut patched = String::with_capacity(block.len() + 32);
patched.push_str(&block[..dash_start]);
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str(&block[dash_end..]);
return Ok(patched);
}
if let Some(line_end) = block[line_start..].find("</a:ln>") {
let line_end = line_start + line_end;
let mut patched = String::with_capacity(block.len() + 32);
patched.push_str(&block[..line_end]);
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str(&block[line_end..]);
return Ok(patched);
}
let line_end = block[line_start..]
.find("/>")
.map(|offset| line_start + offset + 2)
.ok_or_else(|| "shape line entry is missing a closing marker".to_string())?;
let line_tag = &block[line_start..line_end - 2];
let mut patched = String::with_capacity(block.len() + 48);
patched.push_str(&block[..line_start]);
patched.push_str(line_tag);
patched.push('>');
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str("</a:ln>");
patched.push_str(&block[line_end..]);
Ok(patched)
}
fn patch_shape_block_flip(
block: &str,
flip_horizontal: bool,
flip_vertical: bool,
) -> Result<String, String> {
let Some(xfrm_start) = block.find("<a:xfrm") else {
return Err("shape block is missing an `<a:xfrm>` entry for flip styling".to_string());
};
let tag_end = block[xfrm_start..]
.find('>')
.map(|offset| xfrm_start + offset)
.ok_or_else(|| "shape transform entry is missing a closing `>`".to_string())?;
let tag = &block[xfrm_start..=tag_end];
let mut patched_tag = tag.to_string();
patched_tag = upsert_xml_attribute(
&patched_tag,
"flipH",
if flip_horizontal { "1" } else { "0" },
);
patched_tag =
upsert_xml_attribute(&patched_tag, "flipV", if flip_vertical { "1" } else { "0" });
Ok(format!(
"{}{}{}",
&block[..xfrm_start],
patched_tag,
&block[tag_end + 1..]
))
}
fn upsert_xml_attribute(tag: &str, attribute: &str, value: &str) -> String {
let needle = format!(r#"{attribute}=""#);
if let Some(start) = tag.find(&needle) {
let value_start = start + needle.len();
if let Some(end_offset) = tag[value_start..].find('"') {
let end = value_start + end_offset;
return format!("{}{}{}", &tag[..value_start], value, &tag[end..]);
}
}
let insert_at = tag.len() - 1;
format!(r#"{} {attribute}="{value}""#, &tag[..insert_at]) + &tag[insert_at..]
}
fn slide_table_xml(slide: &PresentationSlide) -> String {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let mut table_index = 0_usize;
ordered
.into_iter()
.filter_map(|element| {
let PresentationElement::Table(table) = element else {
return None;
};
table_index += 1;
let rows = table
.rows
.clone()
.into_iter()
.enumerate()
.map(|(row_index, row)| {
let cells = row
.into_iter()
.enumerate()
.map(|(column_index, cell)| {
build_table_cell(cell, &table.merges, row_index, column_index)
})
.collect::<Vec<_>>();
let mut table_row = TableRow::new(cells);
if let Some(height) = table.row_heights.get(row_index) {
table_row = table_row.with_height(points_to_emu(*height));
}
Some(table_row)
})
.collect::<Option<Vec<_>>>()?;
Some(ppt_rs::generator::table::generate_table_xml(
&ppt_rs::generator::table::Table::new(
rows,
table
.column_widths
.iter()
.copied()
.map(points_to_emu)
.collect(),
points_to_emu(table.frame.left),
points_to_emu(table.frame.top),
),
300 + table_index,
))
})
.collect::<Vec<_>>()
.join("\n")
}
fn write_preview_images(
document: &PresentationDocument,
output_dir: &Path,
action: &str,
) -> Result<(), PresentationArtifactError> {
let pptx_path = output_dir.join("preview.pptx");
let bytes = build_pptx_bytes(document, action).map_err(|message| {
PresentationArtifactError::ExportFailed {
path: pptx_path.clone(),
message,
}
})?;
std::fs::write(&pptx_path, bytes).map_err(|error| PresentationArtifactError::ExportFailed {
path: pptx_path.clone(),
message: error.to_string(),
})?;
render_pptx_to_pngs(&pptx_path, output_dir, action)
}
fn render_pptx_to_pngs(
pptx_path: &Path,
output_dir: &Path,
action: &str,
) -> Result<(), PresentationArtifactError> {
let soffice_cmd = if cfg!(target_os = "macos")
&& Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists()
{
"/Applications/LibreOffice.app/Contents/MacOS/soffice"
} else {
"soffice"
};
let conversion = Command::new(soffice_cmd)
.arg("--headless")
.arg("--convert-to")
.arg("pdf")
.arg(pptx_path)
.arg("--outdir")
.arg(output_dir)
.output()
.map_err(|error| PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!("{action}: failed to execute LibreOffice: {error}"),
})?;
if !conversion.status.success() {
return Err(PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!(
"{action}: LibreOffice conversion failed: {}",
String::from_utf8_lossy(&conversion.stderr)
),
});
}
let pdf_path = output_dir.join(
pptx_path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| format!("{stem}.pdf"))
.ok_or_else(|| PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!("{action}: preview pptx filename is invalid"),
})?,
);
let prefix = output_dir.join("slide");
let conversion = Command::new("pdftoppm")
.arg("-png")
.arg(&pdf_path)
.arg(&prefix)
.output()
.map_err(|error| PresentationArtifactError::ExportFailed {
path: pdf_path.clone(),
message: format!("{action}: failed to execute pdftoppm: {error}"),
})?;
std::fs::remove_file(&pdf_path).ok();
if !conversion.status.success() {
return Err(PresentationArtifactError::ExportFailed {
path: output_dir.to_path_buf(),
message: format!(
"{action}: pdftoppm conversion failed: {}",
String::from_utf8_lossy(&conversion.stderr)
),
});
}
Ok(())
}
pub(crate) fn write_preview_image(
source_path: &Path,
target_path: &Path,
format: PreviewOutputFormat,
scale: f32,
quality: u8,
action: &str,
) -> Result<(), PresentationArtifactError> {
if matches!(format, PreviewOutputFormat::Png) && scale == 1.0 {
std::fs::rename(source_path, target_path).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: error.to_string(),
}
})?;
return Ok(());
}
let mut preview =
image::open(source_path).map_err(|error| PresentationArtifactError::ExportFailed {
path: source_path.to_path_buf(),
message: format!("{action}: {error}"),
})?;
if scale != 1.0 {
let width = (preview.width() as f32 * scale).round().max(1.0) as u32;
let height = (preview.height() as f32 * scale).round().max(1.0) as u32;
preview = preview.resize_exact(width, height, FilterType::Lanczos3);
}
let file = std::fs::File::create(target_path).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: error.to_string(),
}
})?;
let mut writer = std::io::BufWriter::new(file);
match format {
PreviewOutputFormat::Png => {
preview
.write_to(&mut writer, ImageFormat::Png)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
})?
}
PreviewOutputFormat::Jpeg => {
let rgb = preview.to_rgb8();
let mut encoder = JpegEncoder::new_with_quality(&mut writer, quality);
encoder.encode_image(&rgb).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
}
})?;
}
PreviewOutputFormat::Svg => {
let mut png_bytes = Cursor::new(Vec::new());
preview
.write_to(&mut png_bytes, ImageFormat::Png)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
})?;
let embedded_png = BASE64_STANDARD.encode(png_bytes.into_inner());
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}"><image href="data:image/png;base64,{embedded_png}" width="{}" height="{}"/></svg>"#,
preview.width(),
preview.height(),
preview.width(),
preview.height(),
preview.width(),
preview.height(),
);
writer.write_all(svg.as_bytes()).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
}
})?;
}
}
std::fs::remove_file(source_path).ok();
Ok(())
}
fn collect_pngs(output_dir: &Path) -> Result<Vec<PathBuf>, PresentationArtifactError> {
let mut files = std::fs::read_dir(output_dir)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: output_dir.to_path_buf(),
message: error.to_string(),
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png"))
.collect::<Vec<_>>();
files.sort();
Ok(files)
}
fn parse_preview_output_format(
format: Option<&str>,
path: &Path,
action: &str,
) -> Result<PreviewOutputFormat, PresentationArtifactError> {
let value = format
.map(str::to_owned)
.or_else(|| {
path.extension()
.and_then(|extension| extension.to_str())
.map(str::to_owned)
})
.unwrap_or_else(|| "png".to_string());
match value.to_ascii_lowercase().as_str() {
"png" => Ok(PreviewOutputFormat::Png),
"jpg" | "jpeg" => Ok(PreviewOutputFormat::Jpeg),
"svg" => Ok(PreviewOutputFormat::Svg),
other => Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("preview format `{other}` is not supported"),
}),
}
}
fn normalize_preview_scale(
scale: Option<f32>,
action: &str,
) -> Result<f32, PresentationArtifactError> {
let scale = scale.unwrap_or(1.0);
if !scale.is_finite() || scale <= 0.0 {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`scale` must be a positive number".to_string(),
});
}
Ok(scale)
}
fn normalize_preview_quality(
quality: Option<u8>,
action: &str,
) -> Result<u8, PresentationArtifactError> {
let quality = quality.unwrap_or(90);
if quality == 0 || quality > 100 {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`quality` must be between 1 and 100".to_string(),
});
}
Ok(quality)
}

View File

@@ -0,0 +1,614 @@
fn document_to_proto(
document: &PresentationDocument,
action: &str,
) -> Result<Value, PresentationArtifactError> {
let layouts = document
.layouts
.iter()
.map(|layout| layout_to_proto(document, layout, action))
.collect::<Result<Vec<_>, _>>()?;
let slides = document
.slides
.iter()
.enumerate()
.map(|(slide_index, slide)| slide_to_proto(slide, slide_index))
.collect::<Vec<_>>();
Ok(serde_json::json!({
"kind": "presentation",
"artifactId": document.artifact_id,
"anchor": format!("pr/{}", document.artifact_id),
"name": document.name,
"slideSize": rect_to_proto(document.slide_size),
"activeSlideIndex": document.active_slide_index,
"activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| slide.slide_id.clone()),
"theme": serde_json::json!({
"colorScheme": document.theme.color_scheme,
"hexColorMap": document.theme.color_scheme,
"majorFont": document.theme.major_font,
"minorFont": document.theme.minor_font,
}),
"styles": document
.named_text_styles()
.iter()
.map(|style| named_text_style_to_json(style, "st"))
.collect::<Vec<_>>(),
"masters": document.layouts.iter().filter(|layout| layout.kind == LayoutKind::Master).map(|layout| layout.layout_id.clone()).collect::<Vec<_>>(),
"layouts": layouts,
"slides": slides,
"commentAuthor": document.comment_self.as_ref().map(comment_author_to_proto),
"commentThreads": document
.comment_threads
.iter()
.map(comment_thread_to_proto)
.collect::<Vec<_>>(),
}))
}
fn layout_to_proto(
document: &PresentationDocument,
layout: &LayoutDocument,
action: &str,
) -> Result<Value, PresentationArtifactError> {
let placeholders = layout
.placeholders
.iter()
.map(placeholder_definition_to_proto)
.collect::<Vec<_>>();
let resolved_placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)?
.into_iter()
.map(|placeholder| {
let mut value = placeholder_definition_to_proto(&placeholder.definition);
value["sourceLayoutId"] = Value::String(placeholder.source_layout_id);
value
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"layoutId": layout.layout_id,
"anchor": format!("ly/{}", layout.layout_id),
"name": layout.name,
"kind": match layout.kind {
LayoutKind::Layout => "layout",
LayoutKind::Master => "master",
},
"parentLayoutId": layout.parent_layout_id,
"placeholders": placeholders,
"resolvedPlaceholders": resolved_placeholders,
}))
}
fn placeholder_definition_to_proto(placeholder: &PlaceholderDefinition) -> Value {
serde_json::json!({
"name": placeholder.name,
"placeholderType": placeholder.placeholder_type,
"index": placeholder.index,
"text": placeholder.text,
"geometry": format!("{:?}", placeholder.geometry),
"frame": rect_to_proto(placeholder.frame),
})
}
fn slide_to_proto(slide: &PresentationSlide, slide_index: usize) -> Value {
serde_json::json!({
"slideId": slide.slide_id,
"anchor": format!("sl/{}", slide.slide_id),
"index": slide_index,
"layoutId": slide.layout_id,
"backgroundFill": slide.background_fill,
"notes": serde_json::json!({
"anchor": format!("nt/{}", slide.slide_id),
"text": slide.notes.text,
"visible": slide.notes.visible,
"textPreview": slide.notes.text.replace('\n', " | "),
"textChars": slide.notes.text.chars().count(),
"textLines": slide.notes.text.lines().count(),
"richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text),
}),
"elements": slide.elements.iter().map(element_to_proto).collect::<Vec<_>>(),
})
}
fn element_to_proto(element: &PresentationElement) -> Value {
match element {
PresentationElement::Text(text) => {
let mut record = serde_json::json!({
"kind": "text",
"elementId": text.element_id,
"anchor": format!("sh/{}", text.element_id),
"frame": rect_to_proto(text.frame),
"text": text.text,
"textPreview": text.text.replace('\n', " | "),
"textChars": text.text.chars().count(),
"textLines": text.text.lines().count(),
"fill": text.fill,
"style": text_style_to_proto(&text.style),
"richText": rich_text_to_proto(&text.text, &text.rich_text),
"zOrder": text.z_order,
});
if let Some(placeholder) = &text.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
if let Some(hyperlink) = &text.hyperlink {
record["hyperlink"] = hyperlink.to_json();
}
record
}
PresentationElement::Shape(shape) => {
let mut record = serde_json::json!({
"kind": "shape",
"elementId": shape.element_id,
"anchor": format!("sh/{}", shape.element_id),
"geometry": format!("{:?}", shape.geometry),
"frame": rect_to_proto(shape.frame),
"fill": shape.fill,
"stroke": shape.stroke.as_ref().map(stroke_to_proto),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"richText": shape
.text
.as_ref()
.zip(shape.rich_text.as_ref())
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
.unwrap_or(Value::Null),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"zOrder": shape.z_order,
});
if let Some(text) = &shape.text {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
if let Some(placeholder) = &shape.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
if let Some(hyperlink) = &shape.hyperlink {
record["hyperlink"] = hyperlink.to_json();
}
record
}
PresentationElement::Connector(connector) => serde_json::json!({
"kind": "connector",
"elementId": connector.element_id,
"anchor": format!("cn/{}", connector.element_id),
"connectorType": format!("{:?}", connector.connector_type),
"start": serde_json::json!({
"left": connector.start.left,
"top": connector.start.top,
"unit": "points",
}),
"end": serde_json::json!({
"left": connector.end.left,
"top": connector.end.top,
"unit": "points",
}),
"line": stroke_to_proto(&connector.line),
"lineStyle": connector.line_style.as_api_str(),
"startArrow": format!("{:?}", connector.start_arrow),
"endArrow": format!("{:?}", connector.end_arrow),
"arrowSize": format!("{:?}", connector.arrow_size),
"label": connector.label,
"zOrder": connector.z_order,
}),
PresentationElement::Image(image) => {
let mut record = serde_json::json!({
"kind": "image",
"elementId": image.element_id,
"anchor": format!("im/{}", image.element_id),
"frame": rect_to_proto(image.frame),
"fit": format!("{:?}", image.fit_mode),
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"lockAspectRatio": image.lock_aspect_ratio,
"alt": image.alt_text,
"prompt": image.prompt,
"isPlaceholder": image.is_placeholder,
"payload": image.payload.as_ref().map(image_payload_to_proto),
"zOrder": image.z_order,
});
if let Some(placeholder) = &image.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
record
}
PresentationElement::Table(table) => serde_json::json!({
"kind": "table",
"elementId": table.element_id,
"anchor": format!("tb/{}", table.element_id),
"frame": rect_to_proto(table.frame),
"rows": table.rows.iter().map(|row| {
row.iter().map(table_cell_to_proto).collect::<Vec<_>>()
}).collect::<Vec<_>>(),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"style": table.style,
"styleOptions": table_style_options_to_proto(&table.style_options),
"borders": table.borders.as_ref().map(table_borders_to_proto),
"rightToLeft": table.right_to_left,
"merges": table.merges.iter().map(|merge| serde_json::json!({
"startRow": merge.start_row,
"endRow": merge.end_row,
"startColumn": merge.start_column,
"endColumn": merge.end_column,
})).collect::<Vec<_>>(),
"zOrder": table.z_order,
}),
PresentationElement::Chart(chart) => serde_json::json!({
"kind": "chart",
"elementId": chart.element_id,
"anchor": format!("ch/{}", chart.element_id),
"frame": rect_to_proto(chart.frame),
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"categories": chart.categories,
"styleIndex": chart.style_index,
"hasLegend": chart.has_legend,
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
"chartFill": chart.chart_fill,
"plotAreaFill": chart.plot_area_fill,
"series": chart.series.iter().map(|series| serde_json::json!({
"name": series.name,
"values": series.values,
"categories": series.categories,
"xValues": series.x_values,
"fill": series.fill,
"stroke": series.stroke.as_ref().map(stroke_to_proto),
"marker": series.marker.as_ref().map(chart_marker_to_proto),
"dataLabelOverrides": series
.data_label_overrides
.iter()
.map(chart_data_label_override_to_proto)
.collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"zOrder": chart.z_order,
}),
}
}
fn rect_to_proto(rect: Rect) -> Value {
serde_json::json!({
"left": rect.left,
"top": rect.top,
"width": rect.width,
"height": rect.height,
"unit": "points",
})
}
fn stroke_to_proto(stroke: &StrokeStyle) -> Value {
serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
"unit": "points",
})
}
fn text_style_to_proto(style: &TextStyle) -> Value {
serde_json::json!({
"styleName": style.style_name,
"fontSize": style.font_size,
"fontFamily": style.font_family,
"color": style.color,
"alignment": style.alignment,
"bold": style.bold,
"italic": style.italic,
"underline": style.underline,
})
}
fn rich_text_to_proto(text: &str, rich_text: &RichTextState) -> Value {
serde_json::json!({
"layout": text_layout_to_proto(&rich_text.layout),
"ranges": rich_text
.ranges
.iter()
.map(|range| text_range_to_proto(text, range))
.collect::<Vec<_>>(),
})
}
fn text_range_to_proto(text: &str, range: &TextRangeAnnotation) -> Value {
serde_json::json!({
"rangeId": range.range_id,
"anchor": format!("tr/{}", range.range_id),
"startCp": range.start_cp,
"length": range.length,
"text": text_slice_by_codepoint_range(text, range.start_cp, range.length),
"style": text_style_to_proto(&range.style),
"hyperlink": range.hyperlink.as_ref().map(HyperlinkState::to_json),
"spacingBefore": range.spacing_before,
"spacingAfter": range.spacing_after,
"lineSpacing": range.line_spacing,
})
}
fn text_layout_to_proto(layout: &TextLayoutState) -> Value {
serde_json::json!({
"insets": layout.insets.map(|insets| serde_json::json!({
"left": insets.left,
"right": insets.right,
"top": insets.top,
"bottom": insets.bottom,
"unit": "points",
})),
"wrap": layout.wrap.map(text_wrap_mode_to_proto),
"autoFit": layout.auto_fit.map(text_auto_fit_mode_to_proto),
"verticalAlignment": layout
.vertical_alignment
.map(text_vertical_alignment_to_proto),
})
}
fn text_wrap_mode_to_proto(mode: TextWrapMode) -> &'static str {
match mode {
TextWrapMode::Square => "square",
TextWrapMode::None => "none",
}
}
fn text_auto_fit_mode_to_proto(mode: TextAutoFitMode) -> &'static str {
match mode {
TextAutoFitMode::None => "none",
TextAutoFitMode::ShrinkText => "shrinkText",
TextAutoFitMode::ResizeShapeToFitText => "resizeShapeToFitText",
}
}
fn text_vertical_alignment_to_proto(alignment: TextVerticalAlignment) -> &'static str {
match alignment {
TextVerticalAlignment::Top => "top",
TextVerticalAlignment::Middle => "middle",
TextVerticalAlignment::Bottom => "bottom",
}
}
fn placeholder_ref_to_proto(placeholder: &PlaceholderRef) -> Value {
serde_json::json!({
"name": placeholder.name,
"placeholderType": placeholder.placeholder_type,
"index": placeholder.index,
})
}
fn image_payload_to_proto(payload: &ImagePayload) -> Value {
serde_json::json!({
"format": payload.format,
"widthPx": payload.width_px,
"heightPx": payload.height_px,
"bytesBase64": BASE64_STANDARD.encode(&payload.bytes),
})
}
fn table_cell_to_proto(cell: &TableCellSpec) -> Value {
serde_json::json!({
"text": cell.text,
"textStyle": text_style_to_proto(&cell.text_style),
"richText": rich_text_to_proto(&cell.text, &cell.rich_text),
"backgroundFill": cell.background_fill,
"alignment": cell.alignment,
"borders": cell.borders.as_ref().map(table_borders_to_proto),
})
}
fn table_style_options_to_proto(style_options: &TableStyleOptions) -> Value {
serde_json::json!({
"headerRow": style_options.header_row,
"bandedRows": style_options.banded_rows,
"bandedColumns": style_options.banded_columns,
"firstColumn": style_options.first_column,
"lastColumn": style_options.last_column,
"totalRow": style_options.total_row,
})
}
fn table_borders_to_proto(borders: &TableBorders) -> Value {
serde_json::json!({
"outside": borders.outside.as_ref().map(table_border_to_proto),
"inside": borders.inside.as_ref().map(table_border_to_proto),
"top": borders.top.as_ref().map(table_border_to_proto),
"bottom": borders.bottom.as_ref().map(table_border_to_proto),
"left": borders.left.as_ref().map(table_border_to_proto),
"right": borders.right.as_ref().map(table_border_to_proto),
})
}
fn table_border_to_proto(border: &TableBorder) -> Value {
serde_json::json!({
"color": border.color,
"width": border.width,
"unit": "points",
})
}
fn chart_marker_to_proto(marker: &ChartMarkerStyle) -> Value {
serde_json::json!({
"symbol": marker.symbol,
"size": marker.size,
})
}
fn chart_data_labels_to_proto(data_labels: &ChartDataLabels) -> Value {
serde_json::json!({
"showValue": data_labels.show_value,
"showCategoryName": data_labels.show_category_name,
"showLeaderLines": data_labels.show_leader_lines,
"position": data_labels.position,
"textStyle": text_style_to_proto(&data_labels.text_style),
})
}
fn chart_legend_to_proto(legend: &ChartLegend) -> Value {
serde_json::json!({
"position": legend.position,
"textStyle": text_style_to_proto(&legend.text_style),
})
}
fn chart_axis_to_proto(axis: &ChartAxisSpec) -> Value {
serde_json::json!({
"title": axis.title,
})
}
fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) -> Value {
serde_json::json!({
"idx": override_spec.idx,
"text": override_spec.text,
"position": override_spec.position,
"textStyle": text_style_to_proto(&override_spec.text_style),
"fill": override_spec.fill,
"stroke": override_spec.stroke.as_ref().map(stroke_to_proto),
})
}
fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
serde_json::json!({
"displayName": author.display_name,
"initials": author.initials,
"email": author.email,
})
}
fn comment_thread_to_proto(thread: &CommentThread) -> Value {
serde_json::json!({
"kind": "comment",
"threadId": thread.thread_id,
"anchor": format!("th/{}", thread.thread_id),
"target": comment_target_to_proto(&thread.target),
"position": thread.position.as_ref().map(comment_position_to_proto),
"status": comment_status_to_proto(thread.status),
"messages": thread.messages.iter().map(comment_message_to_proto).collect::<Vec<_>>(),
})
}
fn comment_target_to_proto(target: &CommentTarget) -> Value {
match target {
CommentTarget::Slide { slide_id } => serde_json::json!({
"type": "slide",
"slideId": slide_id,
"slideAnchor": format!("sl/{slide_id}"),
}),
CommentTarget::Element {
slide_id,
element_id,
} => serde_json::json!({
"type": "element",
"slideId": slide_id,
"slideAnchor": format!("sl/{slide_id}"),
"elementId": element_id,
"elementAnchor": format!("sh/{element_id}"),
}),
CommentTarget::TextRange {
slide_id,
element_id,
start_cp,
length,
context,
} => serde_json::json!({
"type": "textRange",
"slideId": slide_id,
"slideAnchor": format!("sl/{slide_id}"),
"elementId": element_id,
"elementAnchor": format!("sh/{element_id}"),
"startCp": start_cp,
"length": length,
"context": context,
}),
}
}
fn comment_position_to_proto(position: &CommentPosition) -> Value {
serde_json::json!({
"x": position.x,
"y": position.y,
"unit": "points",
})
}
fn comment_message_to_proto(message: &CommentMessage) -> Value {
serde_json::json!({
"messageId": message.message_id,
"author": comment_author_to_proto(&message.author),
"text": message.text,
"createdAt": message.created_at,
"reactions": message.reactions,
})
}
fn comment_status_to_proto(status: CommentThreadStatus) -> &'static str {
match status {
CommentThreadStatus::Active => "active",
CommentThreadStatus::Resolved => "resolved",
}
}
fn text_slice_by_codepoint_range(text: &str, start_cp: usize, length: usize) -> String {
text.chars().skip(start_cp).take(length).collect()
}
fn build_table_cell(
cell: TableCellSpec,
merges: &[TableMergeRegion],
row_index: usize,
column_index: usize,
) -> TableCell {
let mut table_cell = TableCell::new(&cell.text);
if cell.text_style.bold {
table_cell = table_cell.bold();
}
if cell.text_style.italic {
table_cell = table_cell.italic();
}
if cell.text_style.underline {
table_cell = table_cell.underline();
}
if let Some(color) = cell.text_style.color {
table_cell = table_cell.text_color(&color);
}
if let Some(fill) = cell.background_fill {
table_cell = table_cell.background_color(&fill);
}
if let Some(size) = cell.text_style.font_size {
table_cell = table_cell.font_size(size);
}
if let Some(font_family) = cell.text_style.font_family {
table_cell = table_cell.font_family(&font_family);
}
if let Some(alignment) = cell.alignment.or(cell.text_style.alignment) {
table_cell = match alignment {
TextAlignment::Left => table_cell.align_left(),
TextAlignment::Center => table_cell.align_center(),
TextAlignment::Right => table_cell.align_right(),
TextAlignment::Justify => table_cell.align(CellAlign::Justify),
};
}
for merge in merges {
if row_index == merge.start_row && column_index == merge.start_column {
table_cell = table_cell
.grid_span((merge.end_column - merge.start_column + 1) as u32)
.row_span((merge.end_row - merge.start_row + 1) as u32);
} else if row_index >= merge.start_row
&& row_index <= merge.end_row
&& column_index >= merge.start_column
&& column_index <= merge.end_column
{
if row_index == merge.start_row {
table_cell = table_cell.h_merge();
} else {
table_cell = table_cell.v_merge();
}
}
}
table_cell
}

View File

@@ -0,0 +1,138 @@
#[derive(Debug, Clone, Serialize)]
pub struct PresentationArtifactResponse {
pub artifact_id: String,
pub action: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub executed_actions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub exported_paths: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_snapshot: Option<ArtifactSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slide_list: Option<Vec<SlideListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_list: Option<Vec<LayoutListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder_list: Option<Vec<PlaceholderListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<ThemeSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inspect_ndjson: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_record: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proto_json: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patch: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_slide_index: Option<usize>,
}
impl PresentationArtifactResponse {
fn new(
artifact_id: String,
action: String,
summary: String,
artifact_snapshot: ArtifactSnapshot,
) -> Self {
Self {
artifact_id,
action,
summary,
executed_actions: None,
exported_paths: Vec::new(),
artifact_snapshot: Some(artifact_snapshot),
slide_list: None,
layout_list: None,
placeholder_list: None,
theme: None,
inspect_ndjson: None,
resolved_record: None,
proto_json: None,
patch: None,
active_slide_index: None,
}
}
}
fn response_for_document_state(
artifact_id: String,
action: String,
summary: String,
document: Option<&PresentationDocument>,
) -> PresentationArtifactResponse {
PresentationArtifactResponse {
artifact_id,
action,
summary,
executed_actions: None,
exported_paths: Vec::new(),
artifact_snapshot: document.map(snapshot_for_document),
slide_list: None,
layout_list: None,
placeholder_list: None,
theme: document.map(PresentationDocument::theme_snapshot),
inspect_ndjson: None,
resolved_record: None,
proto_json: None,
patch: None,
active_slide_index: document.and_then(|current| current.active_slide_index),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactSnapshot {
pub slide_count: usize,
pub slides: Vec<SlideSnapshot>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SlideSnapshot {
pub slide_id: String,
pub index: usize,
pub element_ids: Vec<String>,
pub element_types: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SlideListEntry {
pub slide_id: String,
pub index: usize,
pub is_active: bool,
pub notes: Option<String>,
pub notes_visible: bool,
pub background_fill: Option<String>,
pub layout_id: Option<String>,
pub element_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct LayoutListEntry {
pub layout_id: String,
pub name: String,
pub kind: String,
pub parent_layout_id: Option<String>,
pub placeholder_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlaceholderListEntry {
pub scope: String,
pub source_layout_id: Option<String>,
pub slide_index: Option<usize>,
pub element_id: Option<String>,
pub name: String,
pub placeholder_type: String,
pub index: Option<u32>,
pub geometry: Option<String>,
pub text_preview: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThemeSnapshot {
pub color_scheme: HashMap<String, String>,
pub hex_color_map: HashMap<String, String>,
pub major_font: Option<String>,
pub minor_font: Option<String>,
}

View File

@@ -0,0 +1,339 @@
fn cell_value_to_string(value: Value) -> String {
match value {
Value::Null => String::new(),
Value::String(text) => text,
Value::Bool(boolean) => boolean.to_string(),
Value::Number(number) => number.to_string(),
other => other.to_string(),
}
}
fn snapshot_for_document(document: &PresentationDocument) -> ArtifactSnapshot {
ArtifactSnapshot {
slide_count: document.slides.len(),
slides: document
.slides
.iter()
.enumerate()
.map(|(index, slide)| SlideSnapshot {
slide_id: slide.slide_id.clone(),
index,
element_ids: slide
.elements
.iter()
.map(|element| element.element_id().to_string())
.collect(),
element_types: slide
.elements
.iter()
.map(|element| element.kind().to_string())
.collect(),
})
.collect(),
}
}
fn slide_list(document: &PresentationDocument) -> Vec<SlideListEntry> {
document
.slides
.iter()
.enumerate()
.map(|(index, slide)| SlideListEntry {
slide_id: slide.slide_id.clone(),
index,
is_active: document.active_slide_index == Some(index),
notes: (slide.notes.visible && !slide.notes.text.is_empty())
.then(|| slide.notes.text.clone()),
notes_visible: slide.notes.visible,
background_fill: slide.background_fill.clone(),
layout_id: slide.layout_id.clone(),
element_count: slide.elements.len(),
})
.collect()
}
fn layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
document
.layouts
.iter()
.map(|layout| LayoutListEntry {
layout_id: layout.layout_id.clone(),
name: layout.name.clone(),
kind: match layout.kind {
LayoutKind::Layout => "layout".to_string(),
LayoutKind::Master => "master".to_string(),
},
parent_layout_id: layout.parent_layout_id.clone(),
placeholder_count: layout.placeholders.len(),
})
.collect()
}
fn points_to_emu(points: u32) -> u32 {
points.saturating_mul(POINT_TO_EMU)
}
fn emu_to_points(emu: u32) -> u32 {
emu / POINT_TO_EMU
}
type ImageCrop = (f64, f64, f64, f64);
type FittedImage = (u32, u32, u32, u32, Option<ImageCrop>);
pub(crate) fn fit_image(image: &ImageElement) -> FittedImage {
let Some(payload) = image.payload.as_ref() else {
return (
image.frame.left,
image.frame.top,
image.frame.width,
image.frame.height,
None,
);
};
let frame = image.frame;
let source_width = payload.width_px as f64;
let source_height = payload.height_px as f64;
let target_width = frame.width as f64;
let target_height = frame.height as f64;
let source_ratio = source_width / source_height;
let target_ratio = target_width / target_height;
match image.fit_mode {
ImageFitMode::Stretch => (frame.left, frame.top, frame.width, frame.height, None),
ImageFitMode::Contain => {
let scale = if source_ratio > target_ratio {
target_width / source_width
} else {
target_height / source_height
};
let width = (source_width * scale).round() as u32;
let height = (source_height * scale).round() as u32;
let left = frame.left + frame.width.saturating_sub(width) / 2;
let top = frame.top + frame.height.saturating_sub(height) / 2;
(left, top, width, height, None)
}
ImageFitMode::Cover => {
let scale = if source_ratio > target_ratio {
target_height / source_height
} else {
target_width / source_width
};
let width = source_width * scale;
let height = source_height * scale;
let crop_x = ((width - target_width).max(0.0) / width) / 2.0;
let crop_y = ((height - target_height).max(0.0) / height) / 2.0;
(
frame.left,
frame.top,
frame.width,
frame.height,
Some((crop_x, crop_y, crop_x, crop_y)),
)
}
}
}
fn normalize_image_crop(
crop: ImageCropArgs,
action: &str,
) -> Result<ImageCrop, PresentationArtifactError> {
for (name, value) in [
("left", crop.left),
("top", crop.top),
("right", crop.right),
("bottom", crop.bottom),
] {
if !(0.0..=1.0).contains(&value) {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("image crop `{name}` must be between 0.0 and 1.0"),
});
}
}
Ok((crop.left, crop.top, crop.right, crop.bottom))
}
fn load_image_payload_from_path(
path: &Path,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let bytes = std::fs::read(path).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{}`: {error}", path.display()),
})?;
build_image_payload(
bytes,
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("image")
.to_string(),
action,
)
}
fn load_image_payload_from_data_url(
data_url: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let (header, payload) =
data_url
.split_once(',')
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "data_url must include a MIME header and base64 payload".to_string(),
})?;
let mime = header
.strip_prefix("data:")
.and_then(|prefix| prefix.strip_suffix(";base64"))
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "data_url must be base64-encoded".to_string(),
})?;
let bytes = BASE64_STANDARD.decode(payload).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image data_url: {error}"),
}
})?;
build_image_payload(
bytes,
format!("image.{}", image_extension_from_mime(mime)),
action,
)
}
fn load_image_payload_from_blob(
blob: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let bytes = BASE64_STANDARD.decode(blob.trim()).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image blob: {error}"),
}
})?;
let extension = image::guess_format(&bytes)
.ok()
.map(image_extension_from_format)
.unwrap_or("png");
build_image_payload(bytes, format!("image.{extension}"), action)
}
fn load_image_payload_from_uri(
uri: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let response =
reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: {error}"),
})?;
let status = response.status();
if !status.is_success() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: HTTP {status}"),
});
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim().to_string());
let bytes = response
.bytes()
.map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{uri}`: {error}"),
})?;
build_image_payload(
bytes.to_vec(),
infer_remote_image_filename(uri, content_type.as_deref()),
action,
)
}
fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String {
let path_name = reqwest::Url::parse(uri)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(Iterator::last)
.map(str::to_owned)
})
.filter(|segment| !segment.is_empty());
match (path_name, content_type) {
(Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name,
(Some(path_name), Some(content_type)) => {
format!("{path_name}.{}", image_extension_from_mime(content_type))
}
(Some(path_name), None) => path_name,
(None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)),
(None, None) => "image.png".to_string(),
}
}
fn build_image_payload(
bytes: Vec<u8>,
filename: String,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let image = image::load_from_memory(&bytes).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image bytes: {error}"),
}
})?;
let (width_px, height_px) = image.dimensions();
let format = Path::new(&filename)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("png")
.to_uppercase();
Ok(ImagePayload {
bytes,
format,
width_px,
height_px,
})
}
fn image_extension_from_mime(mime: &str) -> &'static str {
match mime {
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
}
}
fn image_extension_from_format(format: image::ImageFormat) -> &'static str {
match format {
image::ImageFormat::Jpeg => "jpg",
image::ImageFormat::Gif => "gif",
image::ImageFormat::WebP => "webp",
image::ImageFormat::Bmp => "bmp",
image::ImageFormat::Tiff => "tiff",
_ => "png",
}
}
fn index_out_of_range(action: &str, index: usize, len: usize) -> PresentationArtifactError {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("slide index {index} is out of range for {len} slides"),
}
}
fn to_index(value: u32) -> Result<usize, PresentationArtifactError> {
usize::try_from(value).map_err(|_| PresentationArtifactError::InvalidArgs {
action: "insert_slide".to_string(),
message: "index does not fit in usize".to_string(),
})
}
fn resequence_z_order(slide: &mut PresentationSlide) {
for (index, element) in slide.elements.iter_mut().enumerate() {
element.set_z_order(index);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,25 @@
[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

@@ -0,0 +1,245 @@
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

@@ -0,0 +1,357 @@
use serde::Deserialize;
use serde::Serialize;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpreadsheetChartType {
Area,
Bar,
Doughnut,
Line,
Pie,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpreadsheetChartLegendPosition {
Bottom,
Top,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartLegend {
pub visible: bool,
pub position: SpreadsheetChartLegendPosition,
pub overlay: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartAxis {
pub linked_number_format: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartSeries {
pub id: u32,
pub name: Option<String>,
pub category_sheet_name: Option<String>,
pub category_range: String,
pub value_sheet_name: Option<String>,
pub value_range: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChart {
pub id: u32,
pub chart_type: SpreadsheetChartType,
pub source_sheet_name: Option<String>,
pub source_range: Option<String>,
pub title: Option<String>,
pub style_index: u32,
pub display_blanks_as: String,
pub legend: SpreadsheetChartLegend,
pub category_axis: SpreadsheetChartAxis,
pub value_axis: SpreadsheetChartAxis,
#[serde(default)]
pub series: Vec<SpreadsheetChartSeries>,
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetChartLookup {
pub id: Option<u32>,
pub index: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartCreateOptions {
pub id: Option<u32>,
pub title: Option<String>,
pub legend_visible: Option<bool>,
pub legend_position: Option<SpreadsheetChartLegendPosition>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartProperties {
pub title: Option<String>,
pub legend_visible: Option<bool>,
pub legend_position: Option<SpreadsheetChartLegendPosition>,
}
impl SpreadsheetSheet {
pub fn list_charts(
&self,
range: Option<&CellRange>,
) -> Result<Vec<SpreadsheetChart>, SpreadsheetArtifactError> {
Ok(self
.charts
.iter()
.filter(|chart| {
range.is_none_or(|target| {
chart
.source_range
.as_deref()
.map(CellRange::parse)
.transpose()
.ok()
.flatten()
.is_some_and(|chart_range| chart_range.intersects(target))
})
})
.cloned()
.collect())
}
pub fn get_chart(
&self,
action: &str,
lookup: SpreadsheetChartLookup,
) -> Result<&SpreadsheetChart, SpreadsheetArtifactError> {
if let Some(id) = lookup.id {
return self
.charts
.iter()
.find(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
});
}
if let Some(index) = lookup.index {
return self.charts.get(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.charts.len(),
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
})
}
pub fn create_chart(
&mut self,
action: &str,
chart_type: SpreadsheetChartType,
source_sheet_name: Option<String>,
source_range: &CellRange,
options: SpreadsheetChartCreateOptions,
) -> Result<u32, SpreadsheetArtifactError> {
if source_range.width() < 2 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart source range must include at least two columns".to_string(),
});
}
let id = if let Some(id) = options.id {
if self.charts.iter().any(|chart| chart.id == id) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` already exists"),
});
}
id
} else {
self.charts.iter().map(|chart| chart.id).max().unwrap_or(0) + 1
};
let series = (source_range.start.column + 1..=source_range.end.column)
.enumerate()
.map(|(index, value_column)| SpreadsheetChartSeries {
id: index as u32 + 1,
name: None,
category_sheet_name: source_sheet_name.clone(),
category_range: CellRange::from_start_end(
source_range.start,
CellAddress {
column: source_range.start.column,
row: source_range.end.row,
},
)
.to_a1(),
value_sheet_name: source_sheet_name.clone(),
value_range: CellRange::from_start_end(
CellAddress {
column: value_column,
row: source_range.start.row,
},
CellAddress {
column: value_column,
row: source_range.end.row,
},
)
.to_a1(),
})
.collect::<Vec<_>>();
self.charts.push(SpreadsheetChart {
id,
chart_type,
source_sheet_name,
source_range: Some(source_range.to_a1()),
title: options.title,
style_index: 102,
display_blanks_as: "gap".to_string(),
legend: SpreadsheetChartLegend {
visible: options.legend_visible.unwrap_or(true),
position: options
.legend_position
.unwrap_or(SpreadsheetChartLegendPosition::Bottom),
overlay: false,
},
category_axis: SpreadsheetChartAxis {
linked_number_format: true,
},
value_axis: SpreadsheetChartAxis {
linked_number_format: true,
},
series,
});
Ok(id)
}
pub fn add_chart_series(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
mut series: SpreadsheetChartSeries,
) -> Result<u32, SpreadsheetArtifactError> {
validate_chart_series(action, &series)?;
let chart = self.get_chart_mut(action, lookup)?;
let next_id = chart.series.iter().map(|entry| entry.id).max().unwrap_or(0) + 1;
series.id = next_id;
chart.series.push(series);
Ok(next_id)
}
pub fn delete_chart(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
) -> Result<(), SpreadsheetArtifactError> {
let index = if let Some(id) = lookup.id {
self.charts
.iter()
.position(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
})?
} else if let Some(index) = lookup.index {
if index >= self.charts.len() {
return Err(SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.charts.len(),
});
}
index
} else {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
});
};
self.charts.remove(index);
Ok(())
}
pub fn set_chart_properties(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
properties: SpreadsheetChartProperties,
) -> Result<(), SpreadsheetArtifactError> {
let chart = self.get_chart_mut(action, lookup)?;
if let Some(title) = properties.title {
chart.title = Some(title);
}
if let Some(visible) = properties.legend_visible {
chart.legend.visible = visible;
}
if let Some(position) = properties.legend_position {
chart.legend.position = position;
}
Ok(())
}
pub fn validate_charts(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
for chart in &self.charts {
if let Some(source_range) = &chart.source_range {
let range = CellRange::parse(source_range)?;
if range.width() < 2 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"chart `{}` source range `{source_range}` is too narrow",
chart.id
),
});
}
}
for series in &chart.series {
validate_chart_series(action, series)?;
}
}
Ok(())
}
fn get_chart_mut(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
) -> Result<&mut SpreadsheetChart, SpreadsheetArtifactError> {
if let Some(id) = lookup.id {
return self
.charts
.iter_mut()
.find(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
});
}
if let Some(index) = lookup.index {
let len = self.charts.len();
return self.charts.get_mut(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len,
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
})
}
}
fn validate_chart_series(
action: &str,
series: &SpreadsheetChartSeries,
) -> Result<(), SpreadsheetArtifactError> {
let category_range = CellRange::parse(&series.category_range)?;
let value_range = CellRange::parse(&series.value_range)?;
if !category_range.is_single_column() || !value_range.is_single_column() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart category and value ranges must be single-column ranges".to_string(),
});
}
if category_range.height() != value_range.height() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart category and value series lengths must match".to_string(),
});
}
Ok(())
}

View File

@@ -0,0 +1,308 @@
use serde::Deserialize;
use serde::Serialize;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SpreadsheetConditionalFormatType {
Expression,
CellIs,
ColorScale,
DataBar,
IconSet,
Top10,
UniqueValues,
DuplicateValues,
ContainsText,
NotContainsText,
BeginsWith,
EndsWith,
ContainsBlanks,
NotContainsBlanks,
ContainsErrors,
NotContainsErrors,
TimePeriod,
AboveAverage,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetColorScale {
pub min_type: Option<String>,
pub mid_type: Option<String>,
pub max_type: Option<String>,
pub min_value: Option<String>,
pub mid_value: Option<String>,
pub max_value: Option<String>,
pub min_color: String,
pub mid_color: Option<String>,
pub max_color: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetDataBar {
pub color: String,
pub min_length: Option<u8>,
pub max_length: Option<u8>,
pub show_value: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetIconSet {
pub style: String,
pub show_value: Option<bool>,
pub reverse_order: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetConditionalFormat {
pub id: u32,
pub range: String,
pub rule_type: SpreadsheetConditionalFormatType,
pub operator: Option<String>,
#[serde(default)]
pub formulas: Vec<String>,
pub text: Option<String>,
pub dxf_id: Option<u32>,
pub stop_if_true: bool,
pub priority: u32,
pub rank: Option<u32>,
pub percent: Option<bool>,
pub time_period: Option<String>,
pub above_average: Option<bool>,
pub equal_average: Option<bool>,
pub color_scale: Option<SpreadsheetColorScale>,
pub data_bar: Option<SpreadsheetDataBar>,
pub icon_set: Option<SpreadsheetIconSet>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetConditionalFormatCollection {
pub sheet_name: String,
pub range: String,
}
impl SpreadsheetConditionalFormatCollection {
pub fn new(sheet_name: String, range: &CellRange) -> Self {
Self {
sheet_name,
range: range.to_a1(),
}
}
pub fn range(&self) -> Result<CellRange, SpreadsheetArtifactError> {
CellRange::parse(&self.range)
}
pub fn list(
&self,
artifact: &SpreadsheetArtifact,
) -> Result<Vec<SpreadsheetConditionalFormat>, SpreadsheetArtifactError> {
let sheet = artifact.sheet_lookup(
"conditional_format_collection",
Some(&self.sheet_name),
None,
)?;
Ok(sheet.list_conditional_formats(Some(&self.range()?)))
}
pub fn add(
&self,
artifact: &mut SpreadsheetArtifact,
mut format: SpreadsheetConditionalFormat,
) -> Result<u32, SpreadsheetArtifactError> {
format.range = self.range.clone();
artifact.add_conditional_format("conditional_format_collection", &self.sheet_name, format)
}
pub fn delete(
&self,
artifact: &mut SpreadsheetArtifact,
id: u32,
) -> Result<(), SpreadsheetArtifactError> {
artifact.delete_conditional_format("conditional_format_collection", &self.sheet_name, id)
}
}
impl SpreadsheetArtifact {
pub fn validate_conditional_formats(
&self,
action: &str,
sheet_name: &str,
) -> Result<(), SpreadsheetArtifactError> {
let sheet = self.sheet_lookup(action, Some(sheet_name), None)?;
for format in &sheet.conditional_formats {
validate_conditional_format(self, format, action)?;
}
Ok(())
}
pub fn add_conditional_format(
&mut self,
action: &str,
sheet_name: &str,
mut format: SpreadsheetConditionalFormat,
) -> Result<u32, SpreadsheetArtifactError> {
validate_conditional_format(self, &format, action)?;
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
let next_id = sheet
.conditional_formats
.iter()
.map(|entry| entry.id)
.max()
.unwrap_or(0)
+ 1;
format.id = next_id;
format.priority = if format.priority == 0 {
next_id
} else {
format.priority
};
sheet.conditional_formats.push(format);
Ok(next_id)
}
pub fn delete_conditional_format(
&mut self,
action: &str,
sheet_name: &str,
id: u32,
) -> Result<(), SpreadsheetArtifactError> {
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
let previous_len = sheet.conditional_formats.len();
sheet.conditional_formats.retain(|entry| entry.id != id);
if sheet.conditional_formats.len() == previous_len {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("conditional format `{id}` was not found"),
});
}
Ok(())
}
}
impl SpreadsheetSheet {
pub fn conditional_format_collection(
&self,
range: &CellRange,
) -> SpreadsheetConditionalFormatCollection {
SpreadsheetConditionalFormatCollection::new(self.name.clone(), range)
}
pub fn list_conditional_formats(
&self,
range: Option<&CellRange>,
) -> Vec<SpreadsheetConditionalFormat> {
self.conditional_formats
.iter()
.filter(|entry| {
range.is_none_or(|target| {
CellRange::parse(&entry.range)
.map(|entry_range| entry_range.intersects(target))
.unwrap_or(false)
})
})
.cloned()
.collect()
}
}
fn validate_conditional_format(
artifact: &SpreadsheetArtifact,
format: &SpreadsheetConditionalFormat,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
CellRange::parse(&format.range)?;
if let Some(dxf_id) = format.dxf_id
&& artifact.get_differential_format(dxf_id).is_none()
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("differential format `{dxf_id}` was not found"),
});
}
let has_style = format.dxf_id.is_some();
let has_intrinsic_visual =
format.color_scale.is_some() || format.data_bar.is_some() || format.icon_set.is_some();
match format.rule_type {
SpreadsheetConditionalFormatType::Expression | SpreadsheetConditionalFormatType::CellIs => {
if format.formulas.is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional format formulas are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::ContainsText
| SpreadsheetConditionalFormatType::NotContainsText
| SpreadsheetConditionalFormatType::BeginsWith
| SpreadsheetConditionalFormatType::EndsWith => {
if format.text.as_deref().unwrap_or_default().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional format text is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::ColorScale => {
if format.color_scale.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "color scale settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::DataBar => {
if format.data_bar.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "data bar settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::IconSet => {
if format.icon_set.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "icon set settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::Top10 => {
if format.rank.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "top10 rank is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::TimePeriod => {
if format.time_period.as_deref().unwrap_or_default().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "time period is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::AboveAverage => {}
SpreadsheetConditionalFormatType::UniqueValues
| SpreadsheetConditionalFormatType::DuplicateValues
| SpreadsheetConditionalFormatType::ContainsBlanks
| SpreadsheetConditionalFormatType::NotContainsBlanks
| SpreadsheetConditionalFormatType::ContainsErrors
| SpreadsheetConditionalFormatType::NotContainsErrors => {}
}
if !has_style && !has_intrinsic_visual {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional formatting requires at least one style component".to_string(),
});
}
Ok(())
}

View File

@@ -0,0 +1,39 @@
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 },
}

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