mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge remote-tracking branch 'origin/main' into pakrym/pr-15695-merge-main
# Conflicts: # codex-rs/rollout/src/metadata_tests.rs
This commit is contained in:
Generated
+48
-2
@@ -1881,7 +1881,6 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-execpolicy",
|
||||
"codex-features",
|
||||
"codex-file-search",
|
||||
"codex-git-utils",
|
||||
"codex-hooks",
|
||||
"codex-login",
|
||||
@@ -1889,6 +1888,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-rmcp-client",
|
||||
"codex-rollout",
|
||||
"codex-sandboxing",
|
||||
"codex-secrets",
|
||||
"codex-shell-command",
|
||||
@@ -1902,6 +1902,8 @@ dependencies = [
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-home-dir",
|
||||
"codex-utils-image",
|
||||
"codex-utils-output-truncation",
|
||||
"codex-utils-path",
|
||||
"codex-utils-pty",
|
||||
"codex-utils-readiness",
|
||||
"codex-utils-stream-parser",
|
||||
@@ -1949,7 +1951,6 @@ dependencies = [
|
||||
"test-case",
|
||||
"test-log",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
@@ -2399,6 +2400,7 @@ dependencies = [
|
||||
"codex-git-utils",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-image",
|
||||
"codex-utils-string",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
@@ -2467,6 +2469,31 @@ dependencies = [
|
||||
"which 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-rollout"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"codex-file-search",
|
||||
"codex-git-utils",
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-utils-path",
|
||||
"codex-utils-string",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-sandboxing"
|
||||
version = "0.0.0"
|
||||
@@ -2891,6 +2918,25 @@ dependencies = [
|
||||
"codex-ollama",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-output-truncation"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-string",
|
||||
"pretty_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-path"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-utils-absolute-path",
|
||||
"dunce",
|
||||
"pretty_assertions",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-pty"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -40,6 +40,7 @@ members = [
|
||||
"ollama",
|
||||
"process-hardening",
|
||||
"protocol",
|
||||
"rollout",
|
||||
"rmcp-client",
|
||||
"responses-api-proxy",
|
||||
"sandboxing",
|
||||
@@ -65,6 +66,8 @@ members = [
|
||||
"utils/sleep-inhibitor",
|
||||
"utils/approval-presets",
|
||||
"utils/oss",
|
||||
"utils/output-truncation",
|
||||
"utils/path-utils",
|
||||
"utils/fuzzy-match",
|
||||
"utils/stream-parser",
|
||||
"codex-client",
|
||||
@@ -129,6 +132,7 @@ codex-ollama = { path = "ollama" }
|
||||
codex-otel = { path = "otel" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-rollout = { path = "rollout" }
|
||||
codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
codex-rmcp-client = { path = "rmcp-client" }
|
||||
codex-sandboxing = { path = "sandboxing" }
|
||||
@@ -154,6 +158,8 @@ codex-utils-home-dir = { path = "utils/home-dir" }
|
||||
codex-utils-image = { path = "utils/image" }
|
||||
codex-utils-json-to-toml = { path = "utils/json-to-toml" }
|
||||
codex-utils-oss = { path = "utils/oss" }
|
||||
codex-utils-output-truncation = { path = "utils/output-truncation" }
|
||||
codex-utils-path = { path = "utils/path-utils" }
|
||||
codex-utils-pty = { path = "utils/pty" }
|
||||
codex-utils-readiness = { path = "utils/readiness" }
|
||||
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
|
||||
|
||||
@@ -781,6 +781,36 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FsUnwatchParams": {
|
||||
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
|
||||
"properties": {
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"watchId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FsWatchParams": {
|
||||
"description": "Start filesystem watch notifications for an absolute path.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Absolute file or directory path to watch."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FsWriteFileParams": {
|
||||
"description": "Write a file on the host filesystem.",
|
||||
"properties": {
|
||||
@@ -4000,6 +4030,54 @@
|
||||
"title": "Fs/copyRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/watch"
|
||||
],
|
||||
"title": "Fs/watchRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/FsWatchParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/watchRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/unwatch"
|
||||
],
|
||||
"title": "Fs/unwatchRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/FsUnwatchParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/unwatchRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"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.",
|
||||
"type": "string"
|
||||
},
|
||||
"AccountLoginCompletedNotification": {
|
||||
"properties": {
|
||||
"error": {
|
||||
@@ -998,6 +1002,27 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FsChangedNotification": {
|
||||
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
|
||||
"properties": {
|
||||
"changedPaths": {
|
||||
"description": "File or directory paths associated with this event.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"changedPaths",
|
||||
"watchId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FuzzyFileSearchMatchType": {
|
||||
"enum": [
|
||||
"file",
|
||||
@@ -4394,6 +4419,26 @@
|
||||
"title": "App/list/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/changed"
|
||||
],
|
||||
"title": "Fs/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/FsChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"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.",
|
||||
"type": "string"
|
||||
},
|
||||
"AdditionalFileSystemPermissions": {
|
||||
"properties": {
|
||||
"read": {
|
||||
@@ -883,6 +887,54 @@
|
||||
"title": "Fs/copyRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/watch"
|
||||
],
|
||||
"title": "Fs/watchRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/FsWatchParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/watchRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/unwatch"
|
||||
],
|
||||
"title": "Fs/unwatchRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/FsUnwatchParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/unwatchRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -2257,6 +2309,14 @@
|
||||
"InitializeResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"codexHome": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Absolute path to the server's $CODEX_HOME directory."
|
||||
},
|
||||
"platformFamily": {
|
||||
"description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.",
|
||||
"type": "string"
|
||||
@@ -2270,6 +2330,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"codexHome",
|
||||
"platformFamily",
|
||||
"platformOs",
|
||||
"userAgent"
|
||||
@@ -4053,6 +4114,26 @@
|
||||
"title": "App/list/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/changed"
|
||||
],
|
||||
"title": "Fs/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/FsChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -7444,6 +7525,29 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"FsChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
|
||||
"properties": {
|
||||
"changedPaths": {
|
||||
"description": "File or directory paths associated with this event.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"changedPaths",
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"FsCopyParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Copy a file or directory tree on the host filesystem.",
|
||||
@@ -7698,6 +7802,70 @@
|
||||
"title": "FsRemoveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FsUnwatchParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
|
||||
"properties": {
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsUnwatchParams",
|
||||
"type": "object"
|
||||
},
|
||||
"FsUnwatchResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Successful response for `fs/unwatch`.",
|
||||
"title": "FsUnwatchResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FsWatchParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Start filesystem watch notifications for an absolute path.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Absolute file or directory path to watch."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"title": "FsWatchParams",
|
||||
"type": "object"
|
||||
},
|
||||
"FsWatchResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Created watch handle returned by `fs/watch`.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Canonicalized path associated with the watch."
|
||||
},
|
||||
"watchId": {
|
||||
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsWatchResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FsWriteFileParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Write a file on the host filesystem.",
|
||||
|
||||
@@ -1417,6 +1417,54 @@
|
||||
"title": "Fs/copyRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/watch"
|
||||
],
|
||||
"title": "Fs/watchRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/FsWatchParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/watchRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/unwatch"
|
||||
],
|
||||
"title": "Fs/unwatchRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/FsUnwatchParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/unwatchRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -4037,6 +4085,29 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"FsChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
|
||||
"properties": {
|
||||
"changedPaths": {
|
||||
"description": "File or directory paths associated with this event.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"changedPaths",
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"FsCopyParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Copy a file or directory tree on the host filesystem.",
|
||||
@@ -4291,6 +4362,70 @@
|
||||
"title": "FsRemoveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FsUnwatchParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
|
||||
"properties": {
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsUnwatchParams",
|
||||
"type": "object"
|
||||
},
|
||||
"FsUnwatchResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Successful response for `fs/unwatch`.",
|
||||
"title": "FsUnwatchResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FsWatchParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Start filesystem watch notifications for an absolute path.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Absolute file or directory path to watch."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"title": "FsWatchParams",
|
||||
"type": "object"
|
||||
},
|
||||
"FsWatchResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Created watch handle returned by `fs/watch`.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Canonicalized path associated with the watch."
|
||||
},
|
||||
"watchId": {
|
||||
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsWatchResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FsWriteFileParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Write a file on the host filesystem.",
|
||||
@@ -8540,6 +8675,26 @@
|
||||
"title": "App/list/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"fs/changed"
|
||||
],
|
||||
"title": "Fs/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/FsChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Fs/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"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.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"codexHome": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Absolute path to the server's $CODEX_HOME directory."
|
||||
},
|
||||
"platformFamily": {
|
||||
"description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.",
|
||||
"type": "string"
|
||||
@@ -14,6 +28,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"codexHome",
|
||||
"platformFamily",
|
||||
"platformOs",
|
||||
"userAgent"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"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.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
|
||||
"properties": {
|
||||
"changedPaths": {
|
||||
"description": "File or directory paths associated with this event.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"changedPaths",
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsChangedNotification",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
|
||||
"properties": {
|
||||
"watchId": {
|
||||
"description": "Watch identifier returned by `fs/watch`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsUnwatchParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Successful response for `fs/unwatch`.",
|
||||
"title": "FsUnwatchResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"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.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Start filesystem watch notifications for an absolute path.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Absolute file or directory path to watch."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"title": "FsWatchParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"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.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Created watch handle returned by `fs/watch`.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Canonicalized path associated with the watch."
|
||||
},
|
||||
"watchId": {
|
||||
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"watchId"
|
||||
],
|
||||
"title": "FsWatchResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import type { FsGetMetadataParams } from "./v2/FsGetMetadataParams";
|
||||
import type { FsReadDirectoryParams } from "./v2/FsReadDirectoryParams";
|
||||
import type { FsReadFileParams } from "./v2/FsReadFileParams";
|
||||
import type { FsRemoveParams } from "./v2/FsRemoveParams";
|
||||
import type { FsUnwatchParams } from "./v2/FsUnwatchParams";
|
||||
import type { FsWatchParams } from "./v2/FsWatchParams";
|
||||
import type { FsWriteFileParams } from "./v2/FsWriteFileParams";
|
||||
import type { GetAccountParams } from "./v2/GetAccountParams";
|
||||
import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams";
|
||||
@@ -61,4 +63,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
|
||||
/**
|
||||
* Request from the client to the server.
|
||||
*/
|
||||
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
|
||||
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// 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 { AbsolutePathBuf } from "./AbsolutePathBuf";
|
||||
|
||||
export type InitializeResponse = { userAgent: string,
|
||||
/**
|
||||
* Absolute path to the server's $CODEX_HOME directory.
|
||||
*/
|
||||
codexHome: AbsolutePathBuf,
|
||||
/**
|
||||
* Platform family for the running app-server target, for example
|
||||
* `"unix"` or `"windows"`.
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ContextCompactedNotification } from "./v2/ContextCompactedNotifica
|
||||
import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification";
|
||||
import type { ErrorNotification } from "./v2/ErrorNotification";
|
||||
import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification";
|
||||
import type { FsChangedNotification } from "./v2/FsChangedNotification";
|
||||
import type { HookCompletedNotification } from "./v2/HookCompletedNotification";
|
||||
import type { HookStartedNotification } from "./v2/HookStartedNotification";
|
||||
import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification";
|
||||
@@ -56,4 +57,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
|
||||
/**
|
||||
* Notification sent from the server to the client.
|
||||
*/
|
||||
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
|
||||
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
/**
|
||||
* Filesystem watch notification emitted for `fs/watch` subscribers.
|
||||
*/
|
||||
export type FsChangedNotification = {
|
||||
/**
|
||||
* Watch identifier returned by `fs/watch`.
|
||||
*/
|
||||
watchId: string,
|
||||
/**
|
||||
* File or directory paths associated with this event.
|
||||
*/
|
||||
changedPaths: Array<AbsolutePathBuf>, };
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Stop filesystem watch notifications for a prior `fs/watch`.
|
||||
*/
|
||||
export type FsUnwatchParams = {
|
||||
/**
|
||||
* Watch identifier returned by `fs/watch`.
|
||||
*/
|
||||
watchId: string, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Successful response for `fs/unwatch`.
|
||||
*/
|
||||
export type FsUnwatchResponse = Record<string, never>;
|
||||
@@ -0,0 +1,13 @@
|
||||
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
/**
|
||||
* Start filesystem watch notifications for an absolute path.
|
||||
*/
|
||||
export type FsWatchParams = {
|
||||
/**
|
||||
* Absolute file or directory path to watch.
|
||||
*/
|
||||
path: AbsolutePathBuf, };
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
/**
|
||||
* Created watch handle returned by `fs/watch`.
|
||||
*/
|
||||
export type FsWatchResponse = {
|
||||
/**
|
||||
* Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.
|
||||
*/
|
||||
watchId: string,
|
||||
/**
|
||||
* Canonicalized path associated with the watch.
|
||||
*/
|
||||
path: AbsolutePathBuf, };
|
||||
@@ -97,6 +97,7 @@ export type { FileChangeOutputDeltaNotification } from "./FileChangeOutputDeltaN
|
||||
export type { FileChangeRequestApprovalParams } from "./FileChangeRequestApprovalParams";
|
||||
export type { FileChangeRequestApprovalResponse } from "./FileChangeRequestApprovalResponse";
|
||||
export type { FileUpdateChange } from "./FileUpdateChange";
|
||||
export type { FsChangedNotification } from "./FsChangedNotification";
|
||||
export type { FsCopyParams } from "./FsCopyParams";
|
||||
export type { FsCopyResponse } from "./FsCopyResponse";
|
||||
export type { FsCreateDirectoryParams } from "./FsCreateDirectoryParams";
|
||||
@@ -110,6 +111,10 @@ export type { FsReadFileParams } from "./FsReadFileParams";
|
||||
export type { FsReadFileResponse } from "./FsReadFileResponse";
|
||||
export type { FsRemoveParams } from "./FsRemoveParams";
|
||||
export type { FsRemoveResponse } from "./FsRemoveResponse";
|
||||
export type { FsUnwatchParams } from "./FsUnwatchParams";
|
||||
export type { FsUnwatchResponse } from "./FsUnwatchResponse";
|
||||
export type { FsWatchParams } from "./FsWatchParams";
|
||||
export type { FsWatchResponse } from "./FsWatchResponse";
|
||||
export type { FsWriteFileParams } from "./FsWriteFileParams";
|
||||
export type { FsWriteFileResponse } from "./FsWriteFileResponse";
|
||||
export type { GetAccountParams } from "./GetAccountParams";
|
||||
|
||||
@@ -326,6 +326,14 @@ client_request_definitions! {
|
||||
params: v2::FsCopyParams,
|
||||
response: v2::FsCopyResponse,
|
||||
},
|
||||
FsWatch => "fs/watch" {
|
||||
params: v2::FsWatchParams,
|
||||
response: v2::FsWatchResponse,
|
||||
},
|
||||
FsUnwatch => "fs/unwatch" {
|
||||
params: v2::FsUnwatchParams,
|
||||
response: v2::FsUnwatchResponse,
|
||||
},
|
||||
SkillsConfigWrite => "skills/config/write" {
|
||||
params: v2::SkillsConfigWriteParams,
|
||||
response: v2::SkillsConfigWriteResponse,
|
||||
@@ -899,6 +907,7 @@ server_notification_definitions! {
|
||||
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
|
||||
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
|
||||
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
|
||||
FsChanged => "fs/changed" (v2::FsChangedNotification),
|
||||
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
|
||||
ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification),
|
||||
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
|
||||
@@ -1478,6 +1487,27 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_fs_watch() -> Result<()> {
|
||||
let request = ClientRequest::FsWatch {
|
||||
request_id: RequestId::Integer(10),
|
||||
params: v2::FsWatchParams {
|
||||
path: absolute_path("tmp/repo/.git"),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "fs/watch",
|
||||
"id": 10,
|
||||
"params": {
|
||||
"path": absolute_path_string("tmp/repo/.git")
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_list_experimental_features() -> Result<()> {
|
||||
let request = ClientRequest::ExperimentalFeatureList {
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct InitializeCapabilities {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeResponse {
|
||||
pub user_agent: String,
|
||||
/// Absolute path to the server's $CODEX_HOME directory.
|
||||
pub codex_home: AbsolutePathBuf,
|
||||
/// Platform family for the running app-server target, for example
|
||||
/// `"unix"` or `"windows"`.
|
||||
pub platform_family: String,
|
||||
|
||||
@@ -2301,6 +2301,52 @@ pub struct FsCopyParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct FsCopyResponse {}
|
||||
|
||||
/// Start filesystem watch notifications for an absolute path.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct FsWatchParams {
|
||||
/// Absolute file or directory path to watch.
|
||||
pub path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
/// Created watch handle returned by `fs/watch`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct FsWatchResponse {
|
||||
/// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.
|
||||
pub watch_id: String,
|
||||
/// Canonicalized path associated with the watch.
|
||||
pub path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
/// Stop filesystem watch notifications for a prior `fs/watch`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct FsUnwatchParams {
|
||||
/// Watch identifier returned by `fs/watch`.
|
||||
pub watch_id: String,
|
||||
}
|
||||
|
||||
/// Successful response for `fs/unwatch`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct FsUnwatchResponse {}
|
||||
|
||||
/// Filesystem watch notification emitted for `fs/watch` subscribers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct FsChangedNotification {
|
||||
/// Watch identifier returned by `fs/watch`.
|
||||
pub watch_id: String,
|
||||
/// File or directory paths associated with this event.
|
||||
pub changed_paths: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
/// PTY size in character cells for `command/exec` PTY sessions.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -6497,6 +6543,33 @@ mod tests {
|
||||
assert_eq!(decoded, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_changed_notification_round_trips() {
|
||||
let notification = FsChangedNotification {
|
||||
watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(),
|
||||
changed_paths: vec![
|
||||
absolute_path("tmp/repo/.git/HEAD"),
|
||||
absolute_path("tmp/repo/.git/FETCH_HEAD"),
|
||||
],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
|
||||
"changedPaths": [
|
||||
absolute_path_string("tmp/repo/.git/HEAD"),
|
||||
absolute_path_string("tmp/repo/.git/FETCH_HEAD"),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
let decoded = serde_json::from_value::<FsChangedNotification>(value)
|
||||
.expect("deserialize fs/changed notification");
|
||||
assert_eq!(decoded, notification);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exec_params_default_optional_streaming_flags() {
|
||||
let params = serde_json::from_value::<CommandExecParams>(json!({
|
||||
|
||||
@@ -75,7 +75,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
|
||||
|
||||
## Initialization
|
||||
|
||||
Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services plus `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error.
|
||||
Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services, `codexHome` for the server's Codex home directory, and `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error.
|
||||
|
||||
`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored.
|
||||
|
||||
@@ -159,6 +159,9 @@ Example with notification opt-out:
|
||||
- `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path.
|
||||
- `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`.
|
||||
- `fs/copy` — copy between absolute paths; directory copies require `recursive: true`.
|
||||
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path; returns a `watchId` and canonicalized `path`.
|
||||
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
|
||||
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
|
||||
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
@@ -795,6 +798,28 @@ All filesystem paths in this section must be absolute.
|
||||
- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`.
|
||||
- `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped.
|
||||
|
||||
### Example: Filesystem watch
|
||||
|
||||
`fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations.
|
||||
|
||||
```json
|
||||
{ "method": "fs/watch", "id": 44, "params": {
|
||||
"path": "/Users/me/project/.git/HEAD"
|
||||
} }
|
||||
{ "id": 44, "result": {
|
||||
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
|
||||
"path": "/Users/me/project/.git/HEAD"
|
||||
} }
|
||||
{ "method": "fs/changed", "params": {
|
||||
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
|
||||
"changedPaths": ["/Users/me/project/.git/HEAD"]
|
||||
} }
|
||||
{ "method": "fs/unwatch", "id": 45, "params": {
|
||||
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1"
|
||||
} }
|
||||
{ "id": 45, "result": {} }
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications.
|
||||
|
||||
@@ -893,7 +893,9 @@ impl CodexMessageProcessor {
|
||||
| ClientRequest::FsGetMetadata { .. }
|
||||
| ClientRequest::FsReadDirectory { .. }
|
||||
| ClientRequest::FsRemove { .. }
|
||||
| ClientRequest::FsCopy { .. } => {
|
||||
| ClientRequest::FsCopy { .. }
|
||||
| ClientRequest::FsWatch { .. }
|
||||
| ClientRequest::FsUnwatch { .. } => {
|
||||
warn!("Filesystem request reached CodexMessageProcessor unexpectedly");
|
||||
}
|
||||
ClientRequest::ConfigRequirementsRead { .. } => {
|
||||
|
||||
@@ -159,7 +159,7 @@ impl FsApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
|
||||
pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: message.into(),
|
||||
@@ -167,7 +167,7 @@ fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
|
||||
pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
|
||||
if err.kind() == io::ErrorKind::InvalidInput {
|
||||
invalid_request(err.to_string())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_app_server_protocol::FsChangedNotification;
|
||||
use codex_app_server_protocol::FsUnwatchParams;
|
||||
use codex_app_server_protocol::FsUnwatchResponse;
|
||||
use codex_app_server_protocol::FsWatchParams;
|
||||
use codex_app_server_protocol::FsWatchResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_core::file_watcher::FileWatcher;
|
||||
use codex_core::file_watcher::FileWatcherEvent;
|
||||
use codex_core::file_watcher::FileWatcherSubscriber;
|
||||
use codex_core::file_watcher::Receiver;
|
||||
use codex_core::file_watcher::WatchPath;
|
||||
use codex_core::file_watcher::WatchRegistration;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::hash::Hash;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
#[cfg(test)]
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::Instant;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
struct DebouncedReceiver {
|
||||
rx: Receiver,
|
||||
interval: Duration,
|
||||
changed_paths: HashSet<PathBuf>,
|
||||
next_allowance: Option<Instant>,
|
||||
}
|
||||
|
||||
impl DebouncedReceiver {
|
||||
fn new(rx: Receiver, interval: Duration) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
interval,
|
||||
changed_paths: HashSet::new(),
|
||||
next_allowance: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Option<FileWatcherEvent> {
|
||||
while self.changed_paths.is_empty() {
|
||||
self.changed_paths.extend(self.rx.recv().await?.paths);
|
||||
}
|
||||
let next_allowance = *self
|
||||
.next_allowance
|
||||
.get_or_insert_with(|| Instant::now() + self.interval);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = self.rx.recv() => self.changed_paths.extend(event?.paths),
|
||||
_ = tokio::time::sleep_until(next_allowance) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Some(FileWatcherEvent {
|
||||
paths: self.changed_paths.drain().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FsWatchManager {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
file_watcher: Arc<FileWatcher>,
|
||||
state: Arc<AsyncMutex<FsWatchState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FsWatchState {
|
||||
entries: HashMap<WatchKey, WatchEntry>,
|
||||
}
|
||||
|
||||
struct WatchEntry {
|
||||
terminate_tx: oneshot::Sender<oneshot::Sender<()>>,
|
||||
_subscriber: FileWatcherSubscriber,
|
||||
_registration: WatchRegistration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct WatchKey {
|
||||
connection_id: ConnectionId,
|
||||
watch_id: String,
|
||||
}
|
||||
|
||||
impl FsWatchManager {
|
||||
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>) -> Self {
|
||||
let file_watcher = match FileWatcher::new() {
|
||||
Ok(file_watcher) => Arc::new(file_watcher),
|
||||
Err(err) => {
|
||||
warn!("filesystem watch manager falling back to noop core watcher: {err}");
|
||||
Arc::new(FileWatcher::noop())
|
||||
}
|
||||
};
|
||||
Self::new_with_file_watcher(outgoing, file_watcher)
|
||||
}
|
||||
|
||||
fn new_with_file_watcher(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
file_watcher: Arc<FileWatcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
outgoing,
|
||||
file_watcher,
|
||||
state: Arc::new(AsyncMutex::new(FsWatchState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn watch(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
params: FsWatchParams,
|
||||
) -> Result<FsWatchResponse, JSONRPCErrorError> {
|
||||
let watch_id = Uuid::now_v7().to_string();
|
||||
let outgoing = self.outgoing.clone();
|
||||
let (subscriber, rx) = self.file_watcher.add_subscriber();
|
||||
let watch_root = params.path.to_path_buf().clone();
|
||||
let registration = subscriber.register_paths(vec![WatchPath {
|
||||
path: params.path.to_path_buf(),
|
||||
recursive: false,
|
||||
}]);
|
||||
let (terminate_tx, terminate_rx) = oneshot::channel();
|
||||
|
||||
self.state.lock().await.entries.insert(
|
||||
WatchKey {
|
||||
connection_id,
|
||||
watch_id: watch_id.clone(),
|
||||
},
|
||||
WatchEntry {
|
||||
terminate_tx,
|
||||
_subscriber: subscriber,
|
||||
_registration: registration,
|
||||
},
|
||||
);
|
||||
|
||||
let task_watch_id = watch_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE);
|
||||
tokio::pin!(terminate_rx);
|
||||
loop {
|
||||
let event = tokio::select! {
|
||||
biased;
|
||||
_ = &mut terminate_rx => break,
|
||||
event = rx.recv() => match event {
|
||||
Some(event) => event,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
let mut changed_paths = event
|
||||
.paths
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
match AbsolutePathBuf::resolve_path_against_base(&path, &watch_root) {
|
||||
Ok(path) => Some(path),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to normalize watch event path ({}) for {}: {err}",
|
||||
path.display(),
|
||||
watch_root.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path()));
|
||||
if !changed_paths.is_empty() {
|
||||
outgoing
|
||||
.send_server_notification_to_connection_and_wait(
|
||||
connection_id,
|
||||
ServerNotification::FsChanged(FsChangedNotification {
|
||||
watch_id: task_watch_id.clone(),
|
||||
changed_paths,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(FsWatchResponse {
|
||||
watch_id,
|
||||
path: params.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn unwatch(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
params: FsUnwatchParams,
|
||||
) -> Result<FsUnwatchResponse, JSONRPCErrorError> {
|
||||
let watch_key = WatchKey {
|
||||
connection_id,
|
||||
watch_id: params.watch_id,
|
||||
};
|
||||
let entry = self.state.lock().await.entries.remove(&watch_key);
|
||||
if let Some(entry) = entry {
|
||||
// Wait for the oneshot to be destroyed by the task to ensure that no notifications
|
||||
// are send after the unwatch response.
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
let _ = entry.terminate_tx.send(done_tx);
|
||||
let _ = done_rx.await;
|
||||
}
|
||||
Ok(FsUnwatchResponse {})
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
|
||||
let mut state = self.state.lock().await;
|
||||
state
|
||||
.entries
|
||||
.extract_if(|key, _| key.connection_id == connection_id)
|
||||
.count();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Version;
|
||||
|
||||
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
|
||||
assert!(
|
||||
path.is_absolute(),
|
||||
"path must be absolute: {}",
|
||||
path.display()
|
||||
);
|
||||
AbsolutePathBuf::try_from(path).expect("path should be absolute")
|
||||
}
|
||||
|
||||
fn manager_with_noop_watcher() -> FsWatchManager {
|
||||
const OUTGOING_BUFFER: usize = 1;
|
||||
let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER);
|
||||
FsWatchManager::new_with_file_watcher(
|
||||
Arc::new(OutgoingMessageSender::new(tx)),
|
||||
Arc::new(FileWatcher::noop()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_returns_a_v7_id_and_tracks_the_owner_scoped_entry() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let head_path = temp_dir.path().join("HEAD");
|
||||
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
|
||||
|
||||
let manager = manager_with_noop_watcher();
|
||||
let path = absolute_path(head_path);
|
||||
let response = manager
|
||||
.watch(ConnectionId(1), FsWatchParams { path: path.clone() })
|
||||
.await
|
||||
.expect("watch should succeed");
|
||||
|
||||
assert_eq!(response.path, path);
|
||||
let watch_id = Uuid::parse_str(&response.watch_id).expect("watch id should be a UUID");
|
||||
assert_eq!(watch_id.get_version(), Some(Version::SortRand));
|
||||
|
||||
let state = manager.state.lock().await;
|
||||
assert_eq!(
|
||||
state.entries.keys().cloned().collect::<HashSet<_>>(),
|
||||
HashSet::from([WatchKey {
|
||||
connection_id: ConnectionId(1),
|
||||
watch_id: response.watch_id,
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unwatch_is_scoped_to_the_connection_that_created_the_watch() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let head_path = temp_dir.path().join("HEAD");
|
||||
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
|
||||
|
||||
let manager = manager_with_noop_watcher();
|
||||
let response = manager
|
||||
.watch(
|
||||
ConnectionId(1),
|
||||
FsWatchParams {
|
||||
path: absolute_path(head_path),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("watch should succeed");
|
||||
let watch_key = WatchKey {
|
||||
connection_id: ConnectionId(1),
|
||||
watch_id: response.watch_id.clone(),
|
||||
};
|
||||
|
||||
manager
|
||||
.unwatch(
|
||||
ConnectionId(2),
|
||||
FsUnwatchParams {
|
||||
watch_id: response.watch_id.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("foreign unwatch should be a no-op");
|
||||
assert!(manager.state.lock().await.entries.contains_key(&watch_key));
|
||||
|
||||
manager
|
||||
.unwatch(
|
||||
ConnectionId(1),
|
||||
FsUnwatchParams {
|
||||
watch_id: response.watch_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("owner unwatch should succeed");
|
||||
assert!(!manager.state.lock().await.entries.contains_key(&watch_key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_closed_removes_only_that_connections_watches() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let head_path = temp_dir.path().join("HEAD");
|
||||
let fetch_head_path = temp_dir.path().join("FETCH_HEAD");
|
||||
let packed_refs_path = temp_dir.path().join("packed-refs");
|
||||
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
|
||||
std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD");
|
||||
std::fs::write(&packed_refs_path, "refs\n").expect("write packed-refs");
|
||||
|
||||
let manager = manager_with_noop_watcher();
|
||||
let response_1 = manager
|
||||
.watch(
|
||||
ConnectionId(1),
|
||||
FsWatchParams {
|
||||
path: absolute_path(head_path),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("first watch should succeed");
|
||||
let response_2 = manager
|
||||
.watch(
|
||||
ConnectionId(1),
|
||||
FsWatchParams {
|
||||
path: absolute_path(fetch_head_path),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("second watch should succeed");
|
||||
let response_3 = manager
|
||||
.watch(
|
||||
ConnectionId(2),
|
||||
FsWatchParams {
|
||||
path: absolute_path(packed_refs_path),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("third watch should succeed");
|
||||
|
||||
manager.connection_closed(ConnectionId(1)).await;
|
||||
|
||||
assert_eq!(
|
||||
manager
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.entries
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>(),
|
||||
HashSet::from([WatchKey {
|
||||
connection_id: ConnectionId(2),
|
||||
watch_id: response_3.watch_id,
|
||||
}])
|
||||
);
|
||||
assert_ne!(response_1.watch_id, response_2.watch_id);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ mod error_code;
|
||||
mod external_agent_config_api;
|
||||
mod filters;
|
||||
mod fs_api;
|
||||
mod fs_watch;
|
||||
mod fuzzy_file_search;
|
||||
pub mod in_process;
|
||||
mod message_processor;
|
||||
|
||||
@@ -8,9 +8,11 @@ use std::sync::atomic::Ordering;
|
||||
use crate::codex_message_processor::CodexMessageProcessor;
|
||||
use crate::codex_message_processor::CodexMessageProcessorArgs;
|
||||
use crate::config_api::ConfigApi;
|
||||
use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
||||
use crate::external_agent_config_api::ExternalAgentConfigApi;
|
||||
use crate::fs_api::FsApi;
|
||||
use crate::fs_watch::FsWatchManager;
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::ConnectionRequestId;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
@@ -36,6 +38,8 @@ use codex_app_server_protocol::FsGetMetadataParams;
|
||||
use codex_app_server_protocol::FsReadDirectoryParams;
|
||||
use codex_app_server_protocol::FsReadFileParams;
|
||||
use codex_app_server_protocol::FsRemoveParams;
|
||||
use codex_app_server_protocol::FsUnwatchParams;
|
||||
use codex_app_server_protocol::FsWatchParams;
|
||||
use codex_app_server_protocol::FsWriteFileParams;
|
||||
use codex_app_server_protocol::InitializeResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
@@ -152,6 +156,7 @@ pub(crate) struct MessageProcessor {
|
||||
external_agent_config_api: ExternalAgentConfigApi,
|
||||
fs_api: FsApi,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
fs_watch_manager: FsWatchManager,
|
||||
config: Arc<Config>,
|
||||
config_warnings: Arc<Vec<ConfigWarningNotification>>,
|
||||
}
|
||||
@@ -248,6 +253,7 @@ impl MessageProcessor {
|
||||
);
|
||||
let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone());
|
||||
let fs_api = FsApi::default();
|
||||
let fs_watch_manager = FsWatchManager::new(outgoing.clone());
|
||||
|
||||
Self {
|
||||
outgoing,
|
||||
@@ -256,6 +262,7 @@ impl MessageProcessor {
|
||||
external_agent_config_api,
|
||||
fs_api,
|
||||
auth_manager,
|
||||
fs_watch_manager,
|
||||
config,
|
||||
config_warnings: Arc::new(config_warnings),
|
||||
}
|
||||
@@ -467,6 +474,7 @@ impl MessageProcessor {
|
||||
|
||||
pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) {
|
||||
self.outgoing.connection_closed(connection_id).await;
|
||||
self.fs_watch_manager.connection_closed(connection_id).await;
|
||||
self.codex_message_processor
|
||||
.connection_closed(connection_id)
|
||||
.await;
|
||||
@@ -583,8 +591,21 @@ impl MessageProcessor {
|
||||
}
|
||||
|
||||
let user_agent = get_codex_user_agent();
|
||||
let codex_home = match self.config.codex_home.clone().try_into() {
|
||||
Ok(codex_home) => codex_home,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("Invalid CODEX_HOME: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(connection_request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let response = InitializeResponse {
|
||||
user_agent,
|
||||
codex_home,
|
||||
platform_family: std::env::consts::FAMILY.to_string(),
|
||||
platform_os: std::env::consts::OS.to_string(),
|
||||
};
|
||||
@@ -759,6 +780,28 @@ impl MessageProcessor {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::FsWatch { request_id, params } => {
|
||||
self.handle_fs_watch(
|
||||
ConnectionRequestId {
|
||||
connection_id,
|
||||
request_id,
|
||||
},
|
||||
connection_id,
|
||||
params,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::FsUnwatch { request_id, params } => {
|
||||
self.handle_fs_unwatch(
|
||||
ConnectionRequestId {
|
||||
connection_id,
|
||||
request_id,
|
||||
},
|
||||
connection_id,
|
||||
params,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
other => {
|
||||
// Box the delegated future so this wrapper's async state machine does not
|
||||
// inline the full `CodexMessageProcessor::process_request` future, which
|
||||
@@ -910,6 +953,30 @@ impl MessageProcessor {
|
||||
Err(error) => self.outgoing.send_error(request_id, error).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_fs_watch(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
connection_id: ConnectionId,
|
||||
params: FsWatchParams,
|
||||
) {
|
||||
match self.fs_watch_manager.watch(connection_id, params).await {
|
||||
Ok(response) => self.outgoing.send_response(request_id, response).await,
|
||||
Err(error) => self.outgoing.send_error(request_id, error).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_fs_unwatch(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
connection_id: ConnectionId,
|
||||
params: FsUnwatchParams,
|
||||
) {
|
||||
match self.fs_watch_manager.unwatch(connection_id, params).await {
|
||||
Ok(response) => self.outgoing.send_response(request_id, response).await,
|
||||
Err(error) => self.outgoing.send_error(request_id, error).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -31,6 +31,8 @@ use codex_app_server_protocol::FsGetMetadataParams;
|
||||
use codex_app_server_protocol::FsReadDirectoryParams;
|
||||
use codex_app_server_protocol::FsReadFileParams;
|
||||
use codex_app_server_protocol::FsRemoveParams;
|
||||
use codex_app_server_protocol::FsUnwatchParams;
|
||||
use codex_app_server_protocol::FsWatchParams;
|
||||
use codex_app_server_protocol::FsWriteFileParams;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
@@ -790,6 +792,19 @@ impl McpProcess {
|
||||
self.send_request("fs/copy", params).await
|
||||
}
|
||||
|
||||
pub async fn send_fs_watch_request(&mut self, params: FsWatchParams) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("fs/watch", params).await
|
||||
}
|
||||
|
||||
pub async fn send_fs_unwatch_request(
|
||||
&mut self,
|
||||
params: FsUnwatchParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("fs/unwatch", params).await
|
||||
}
|
||||
|
||||
/// Send an `account/logout` JSON-RPC request.
|
||||
pub async fn send_logout_account_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("account/logout", /*params*/ None).await
|
||||
|
||||
@@ -4,11 +4,15 @@ use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use codex_app_server_protocol::FsChangedNotification;
|
||||
use codex_app_server_protocol::FsCopyParams;
|
||||
use codex_app_server_protocol::FsGetMetadataResponse;
|
||||
use codex_app_server_protocol::FsReadDirectoryEntry;
|
||||
use codex_app_server_protocol::FsReadFileResponse;
|
||||
use codex_app_server_protocol::FsUnwatchParams;
|
||||
use codex_app_server_protocol::FsWatchResponse;
|
||||
use codex_app_server_protocol::FsWriteFileParams;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -17,6 +21,8 @@ use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
use uuid::Version;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::symlink;
|
||||
@@ -611,3 +617,195 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let git_dir = codex_home.path().join("repo").join(".git");
|
||||
let fetch_head = git_dir.join("FETCH_HEAD");
|
||||
std::fs::create_dir_all(&git_dir)?;
|
||||
std::fs::write(&fetch_head, "old\n")?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let watch_request_id = mcp
|
||||
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
|
||||
path: absolute_path(git_dir.clone()),
|
||||
})
|
||||
.await?;
|
||||
let watch_response: FsWatchResponse = to_response(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
assert_eq!(watch_response.path, absolute_path(git_dir.clone()));
|
||||
let watch_id = Uuid::parse_str(&watch_response.watch_id)?;
|
||||
assert_eq!(watch_id.get_version(), Some(Version::SortRand));
|
||||
|
||||
std::fs::write(&fetch_head, "updated\n")?;
|
||||
|
||||
// Kernel file watching is not reliable in every sandboxed test environment.
|
||||
// Keep validating notification shape when the backend does emit, but do not
|
||||
// fail the whole suite if no OS event arrives.
|
||||
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
|
||||
assert_eq!(changed.watch_id, watch_response.watch_id.clone());
|
||||
assert_eq!(
|
||||
changed.changed_paths,
|
||||
vec![absolute_path(fetch_head.clone())]
|
||||
);
|
||||
}
|
||||
while timeout(
|
||||
Duration::from_millis(200),
|
||||
mcp.read_stream_until_notification_message("fs/changed"),
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{}
|
||||
|
||||
let unwatch_request_id = mcp
|
||||
.send_fs_unwatch_request(FsUnwatchParams {
|
||||
watch_id: watch_response.watch_id,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(unwatch_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
std::fs::write(git_dir.join("packed-refs"), "refs\n")?;
|
||||
let maybe_notification = timeout(
|
||||
Duration::from_millis(1500),
|
||||
mcp.read_stream_until_notification_message("fs/changed"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
maybe_notification.is_err(),
|
||||
"fs/unwatch should stop future change notifications"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let git_dir = codex_home.path().join("repo").join(".git");
|
||||
let head_path = git_dir.join("HEAD");
|
||||
std::fs::create_dir_all(&git_dir)?;
|
||||
std::fs::write(&head_path, "ref: refs/heads/main\n")?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let watch_request_id = mcp
|
||||
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
|
||||
path: absolute_path(head_path.clone()),
|
||||
})
|
||||
.await?;
|
||||
let watch_response: FsWatchResponse = to_response(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
assert_eq!(watch_response.path, absolute_path(head_path.clone()));
|
||||
|
||||
replace_file_atomically(&head_path, "ref: refs/heads/feature\n")?;
|
||||
|
||||
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
|
||||
assert_eq!(
|
||||
changed,
|
||||
FsChangedNotification {
|
||||
watch_id: watch_response.watch_id,
|
||||
changed_paths: vec![absolute_path(head_path.clone())],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_watch_allows_missing_file_targets() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let git_dir = codex_home.path().join("repo").join(".git");
|
||||
let fetch_head = git_dir.join("FETCH_HEAD");
|
||||
std::fs::create_dir_all(&git_dir)?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let watch_request_id = mcp
|
||||
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
|
||||
path: absolute_path(fetch_head.clone()),
|
||||
})
|
||||
.await?;
|
||||
let watch_response: FsWatchResponse = to_response(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
assert_eq!(watch_response.path, absolute_path(fetch_head.clone()));
|
||||
|
||||
replace_file_atomically(&fetch_head, "origin/main\n")?;
|
||||
|
||||
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
|
||||
assert_eq!(
|
||||
changed,
|
||||
FsChangedNotification {
|
||||
watch_id: watch_response.watch_id,
|
||||
changed_paths: vec![absolute_path(fetch_head.clone())],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_watch_rejects_relative_paths() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
|
||||
let watch_id = mcp
|
||||
.send_raw_request("fs/watch", Some(json!({ "path": "relative-path" })))
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
watch_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fs_changed_notification(notification: JSONRPCNotification) -> Result<FsChangedNotification> {
|
||||
let params = notification
|
||||
.params
|
||||
.context("fs/changed notification should include params")?;
|
||||
Ok(serde_json::from_value::<FsChangedNotification>(params)?)
|
||||
}
|
||||
|
||||
async fn maybe_fs_changed_notification(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<Option<FsChangedNotification>> {
|
||||
match timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("fs/changed"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(notification) => Ok(Some(fs_changed_notification(notification?)?)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> {
|
||||
let temp_path = path.with_extension("lock");
|
||||
std::fs::write(&temp_path, contents)?;
|
||||
std::fs::rename(temp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cargo_bin::cargo_bin;
|
||||
use core_test_support::fs_wait;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -30,6 +31,7 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
|
||||
let responses = Vec::new();
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), "never")?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
|
||||
@@ -48,11 +50,13 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
|
||||
};
|
||||
let InitializeResponse {
|
||||
user_agent,
|
||||
codex_home: response_codex_home,
|
||||
platform_family,
|
||||
platform_os,
|
||||
} = to_response::<InitializeResponse>(response)?;
|
||||
|
||||
assert!(user_agent.starts_with("codex_vscode/"));
|
||||
assert_eq!(response_codex_home, expected_codex_home);
|
||||
assert_eq!(platform_family, std::env::consts::FAMILY);
|
||||
assert_eq!(platform_os, std::env::consts::OS);
|
||||
Ok(())
|
||||
@@ -63,6 +67,7 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> {
|
||||
let responses = Vec::new();
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), "never")?;
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
@@ -88,11 +93,13 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> {
|
||||
};
|
||||
let InitializeResponse {
|
||||
user_agent,
|
||||
codex_home: response_codex_home,
|
||||
platform_family,
|
||||
platform_os,
|
||||
} = to_response::<InitializeResponse>(response)?;
|
||||
|
||||
assert!(user_agent.starts_with("codex_originator_via_env_var/"));
|
||||
assert_eq!(response_codex_home, expected_codex_home);
|
||||
assert_eq!(platform_family, std::env::consts::FAMILY);
|
||||
assert_eq!(platform_os, std::env::consts::OS);
|
||||
Ok(())
|
||||
|
||||
@@ -40,13 +40,13 @@ codex-login = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-artifacts = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rollout = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
@@ -55,6 +55,8 @@ codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-cache = { workspace = true }
|
||||
codex-utils-image = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
codex-utils-pty = { workspace = true }
|
||||
codex-utils-readiness = { workspace = true }
|
||||
codex-secrets = { workspace = true }
|
||||
@@ -94,12 +96,6 @@ similar = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
test-log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
time = { workspace = true, features = [
|
||||
"formatting",
|
||||
"parsing",
|
||||
"local-offset",
|
||||
"macros",
|
||||
] }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
|
||||
@@ -46,7 +46,6 @@ use crate::stream_events_utils::handle_output_item_done;
|
||||
use crate::stream_events_utils::last_assistant_message_from_item;
|
||||
use crate::stream_events_utils::raw_assistant_output_text_from_item;
|
||||
use crate::stream_events_utils::record_completed_response_item;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::turn_metadata::TurnMetadataState;
|
||||
use crate::util::error_or_panic;
|
||||
use async_channel::Receiver;
|
||||
@@ -117,6 +116,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_terminal_detection::user_agent;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_stream_parser::AssistantTextChunk;
|
||||
use codex_utils_stream_parser::AssistantTextStreamParser;
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
@@ -1007,7 +1007,7 @@ impl TurnContext {
|
||||
user_instructions: self.user_instructions.clone(),
|
||||
developer_instructions: self.developer_instructions.clone(),
|
||||
final_output_json_schema: self.final_output_json_schema.clone(),
|
||||
truncation_policy: Some(self.truncation_policy.into()),
|
||||
truncation_policy: Some(self.truncation_policy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1570,7 +1570,7 @@ impl Session {
|
||||
})?;
|
||||
let rollout_path = rollout_recorder
|
||||
.as_ref()
|
||||
.map(|rec| rec.rollout_path.clone());
|
||||
.map(|rec| rec.rollout_path().to_path_buf());
|
||||
|
||||
let mut post_session_configured_events = Vec::<Event>::new();
|
||||
|
||||
@@ -2215,15 +2215,6 @@ impl Session {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context).await;
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
|
||||
}
|
||||
|
||||
// Forked threads should remain file-backed immediately after startup.
|
||||
self.ensure_rollout_materialized().await;
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)];
|
||||
|
||||
@@ -116,7 +116,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -866,7 +866,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -938,7 +938,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
}))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
@@ -967,7 +967,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1073,7 +1073,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
|
||||
let rollout_items = vec![
|
||||
@@ -1175,7 +1175,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1319,7 +1319,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
|
||||
@@ -1115,18 +1115,12 @@ async fn recompute_token_usage_updates_model_context_window() {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
let reconstruction_turn = session.new_default_turn().await;
|
||||
expected.extend(
|
||||
session
|
||||
.build_initial_context(reconstruction_turn.as_ref())
|
||||
.await,
|
||||
);
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -1244,7 +1238,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
|
||||
|
||||
let request = first_forked_request.single_request();
|
||||
let snapshot = context_snapshot::format_labeled_requests_snapshot(
|
||||
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
|
||||
"First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.",
|
||||
&[("First Forked Turn Request", &request)],
|
||||
&ContextSnapshotOptions::default()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
|
||||
@@ -1287,7 +1281,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1309,7 +1303,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::TurnContext(previous_context_item.clone()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
turn_id,
|
||||
@@ -1322,6 +1316,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
let history = session.clone_history().await;
|
||||
assert_eq!(
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
@@ -1329,6 +1324,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
assert_eq!(history.raw_items(), &[]);
|
||||
assert_eq!(
|
||||
serde_json::to_value(session.reference_context_item().await)
|
||||
.expect("serialize fork reference context item"),
|
||||
serde_json::to_value(Some(previous_context_item))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -15,9 +15,6 @@ use crate::protocol::CompactedItem;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::truncate_text;
|
||||
use crate::util::backoff;
|
||||
use codex_protocol::items::ContextCompactionItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
@@ -25,6 +22,9 @@ use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use futures::prelude::*;
|
||||
use tracing::error;
|
||||
|
||||
|
||||
@@ -237,6 +237,54 @@ async fn read_includes_origins_and_layers() {
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?;
|
||||
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["~/code"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
key_path: "model".to_string(),
|
||||
value: serde_json::json!("updated"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect("write succeeds");
|
||||
|
||||
assert_eq!(response.status, WriteStatus::Ok);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"),
|
||||
"model = \"updated\"\n"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_value_reports_override() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -285,9 +285,11 @@ pub async fn load_config_layers_state(
|
||||
));
|
||||
}
|
||||
if let Some(config) = managed_config_from_mdm {
|
||||
let managed_config =
|
||||
resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?;
|
||||
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
config.managed_config,
|
||||
managed_config,
|
||||
config.raw_toml,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -380,6 +380,54 @@ flag = false
|
||||
assert!(raw.contains("value = \"managed\""));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()>
|
||||
{
|
||||
use base64::Engine;
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["~/code"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?;
|
||||
match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
assert_eq!(
|
||||
writable_roots
|
||||
.iter()
|
||||
.filter(|root| **root == expected_root)
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
other => panic!("expected workspace-write policy, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> {
|
||||
|
||||
@@ -3,12 +3,6 @@ use crate::context_manager::normalize;
|
||||
use crate::event_mapping::has_non_contextual_dev_message_content;
|
||||
use crate::event_mapping::is_contextual_dev_message_content;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count_i64;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
use crate::truncate::truncate_text;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
@@ -25,6 +19,12 @@ use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_utils_cache::BlockingLruCache;
|
||||
use codex_utils_cache::sha1_digest;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_bytes_for_tokens;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::approx_tokens_from_byte_count_i64;
|
||||
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::ops::Deref;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::*;
|
||||
use crate::truncate;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_git_utils::GhostCommit;
|
||||
@@ -23,6 +21,8 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use image::ImageBuffer;
|
||||
use image::ImageFormat;
|
||||
use image::Rgba;
|
||||
@@ -179,7 +179,7 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem {
|
||||
}
|
||||
|
||||
fn truncate_exec_output(content: &str) -> String {
|
||||
truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
|
||||
truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
|
||||
}
|
||||
|
||||
fn approx_token_count_for_text(text: &str) -> i64 {
|
||||
|
||||
@@ -193,6 +193,10 @@ pub(crate) fn build_settings_update_items(
|
||||
exec_policy: &Policy,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
// TODO(ccunningham): build_settings_update_items still does not cover every
|
||||
// model-visible item emitted by build_initial_context. Persist the remaining
|
||||
// inputs or add explicit replay events so fork/resume can diff everything
|
||||
// deterministically.
|
||||
let contextual_user_message = build_environment_update_item(previous, next, shell);
|
||||
let developer_update_sections = [
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
|
||||
@@ -2,8 +2,6 @@ use crate::exec::ExecToolCallOutput;
|
||||
use crate::network_policy_decision::NetworkPolicyDecisionPayload;
|
||||
use crate::token_data::KnownPlan;
|
||||
use crate::token_data::PlanType;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use chrono::DateTime;
|
||||
use chrono::Datelike;
|
||||
use chrono::Local;
|
||||
@@ -15,6 +13,8 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json;
|
||||
use std::io;
|
||||
|
||||
@@ -7,9 +7,9 @@ use serde_json::Value;
|
||||
use crate::codex::Session;
|
||||
use crate::compact::content_items_to_text;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count;
|
||||
use codex_utils_output_truncation::approx_bytes_for_tokens;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::approx_tokens_from_byte_count;
|
||||
|
||||
use super::GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS;
|
||||
use super::GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS;
|
||||
|
||||
@@ -32,7 +32,7 @@ pub mod connectors;
|
||||
mod context_manager;
|
||||
mod contextual_user_message;
|
||||
pub mod custom_prompts;
|
||||
pub mod env;
|
||||
pub use codex_utils_path::env;
|
||||
mod environment_context;
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
@@ -65,7 +65,8 @@ pub mod mention_syntax;
|
||||
mod mentions;
|
||||
pub mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod path_utils;
|
||||
pub mod utils;
|
||||
pub use utils::path_utils;
|
||||
pub mod personality_migration;
|
||||
pub mod plugins;
|
||||
mod sandbox_tags;
|
||||
@@ -78,7 +79,6 @@ mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod text_encoding;
|
||||
pub use codex_login::token_data;
|
||||
mod truncate;
|
||||
mod unified_exec;
|
||||
pub mod windows_sandbox;
|
||||
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
|
||||
@@ -124,11 +124,14 @@ pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
mod session_rollout_init_error;
|
||||
pub mod shell;
|
||||
pub mod shell_snapshot;
|
||||
pub mod skills;
|
||||
pub mod spawn;
|
||||
pub mod state_db;
|
||||
pub mod state_db_bridge;
|
||||
pub use codex_rollout::state_db;
|
||||
mod thread_rollout_truncation;
|
||||
mod tools;
|
||||
pub mod turn_diff_tracker;
|
||||
mod turn_metadata;
|
||||
|
||||
@@ -1440,7 +1440,12 @@ async fn make_rmcp_client(
|
||||
} => {
|
||||
let command_os: OsString = command.into();
|
||||
let args_os: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env, &env_vars, cwd)
|
||||
let env_os = env.map(|env| {
|
||||
env.into_iter()
|
||||
.map(|(key, value)| (key.into(), value.into()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
});
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd)
|
||||
.await
|
||||
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::phase_one;
|
||||
use crate::memories::storage::rollout_summary_file_stem_from_parts;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use askama::Template;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_state::Phase2InputSelection;
|
||||
use codex_state::Stage1Output;
|
||||
use codex_state::Stage1OutputRef;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -10,8 +10,8 @@ use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_output_truncation::approx_bytes_for_tokens;
|
||||
use tracing::warn;
|
||||
|
||||
pub const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md");
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::codex::Session;
|
||||
use crate::compact::content_items_to_text;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use chrono::Utc;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_state::SortKey;
|
||||
use codex_state::ThreadMetadata;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use dirs::home_dir;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::config::Config;
|
||||
pub use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
pub use codex_rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
pub use codex_rollout::RolloutRecorder;
|
||||
pub use codex_rollout::RolloutRecorderParams;
|
||||
pub use codex_rollout::SESSIONS_SUBDIR;
|
||||
pub use codex_rollout::SessionMeta;
|
||||
pub use codex_rollout::append_thread_name;
|
||||
pub use codex_rollout::find_archived_thread_path_by_id_str;
|
||||
#[deprecated(note = "use find_thread_path_by_id_str")]
|
||||
pub use codex_rollout::find_conversation_path_by_id_str;
|
||||
pub use codex_rollout::find_thread_name_by_id;
|
||||
pub use codex_rollout::find_thread_path_by_id_str;
|
||||
pub use codex_rollout::find_thread_path_by_name_str;
|
||||
pub use codex_rollout::rollout_date_parts;
|
||||
|
||||
impl codex_rollout::RolloutConfigView for Config {
|
||||
fn codex_home(&self) -> &std::path::Path {
|
||||
self.codex_home.as_path()
|
||||
}
|
||||
|
||||
fn sqlite_home(&self) -> &std::path::Path {
|
||||
self.sqlite_home.as_path()
|
||||
}
|
||||
|
||||
fn cwd(&self) -> &std::path::Path {
|
||||
self.cwd.as_path()
|
||||
}
|
||||
|
||||
fn model_provider_id(&self) -> &str {
|
||||
self.model_provider_id.as_str()
|
||||
}
|
||||
|
||||
fn generate_memories(&self) -> bool {
|
||||
self.memories.generate_memories
|
||||
}
|
||||
}
|
||||
|
||||
pub mod list {
|
||||
pub use codex_rollout::list::*;
|
||||
}
|
||||
|
||||
pub(crate) mod metadata {
|
||||
pub(crate) use codex_rollout::metadata::builder_from_items;
|
||||
}
|
||||
|
||||
pub mod policy {
|
||||
pub use codex_rollout::policy::*;
|
||||
}
|
||||
|
||||
pub mod recorder {
|
||||
pub use codex_rollout::recorder::*;
|
||||
}
|
||||
|
||||
pub mod session_index {
|
||||
pub use codex_rollout::session_index::*;
|
||||
}
|
||||
|
||||
pub(crate) use crate::session_rollout_init_error::map_session_init_error;
|
||||
|
||||
pub(crate) mod truncation {
|
||||
pub(crate) use crate::thread_rollout_truncation::*;
|
||||
}
|
||||
+4
-6
@@ -1,17 +1,15 @@
|
||||
---
|
||||
source: core/src/codex_tests.rs
|
||||
assertion_line: 1282
|
||||
assertion_line: 1254
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.
|
||||
Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.
|
||||
|
||||
## First Forked Turn Request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:fork seed
|
||||
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
05:message/developer[2]:
|
||||
03:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
|
||||
06:message/user:after fork
|
||||
04:message/user:after fork
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
|
||||
/// Persistent, session-scoped state previously stored directly on `Session`.
|
||||
pub(crate) struct SessionState {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use codex_rollout::state_db as rollout_state_db;
|
||||
pub use codex_rollout::state_db::StateDbHandle;
|
||||
pub use codex_rollout::state_db::apply_rollout_items;
|
||||
pub use codex_rollout::state_db::find_rollout_path_by_id;
|
||||
pub use codex_rollout::state_db::get_dynamic_tools;
|
||||
pub use codex_rollout::state_db::list_thread_ids_db;
|
||||
pub use codex_rollout::state_db::list_threads_db;
|
||||
pub use codex_rollout::state_db::mark_thread_memory_mode_polluted;
|
||||
pub use codex_rollout::state_db::normalize_cwd_for_state_db;
|
||||
pub use codex_rollout::state_db::open_if_present;
|
||||
pub use codex_rollout::state_db::persist_dynamic_tools;
|
||||
pub use codex_rollout::state_db::read_repair_rollout_path;
|
||||
pub use codex_rollout::state_db::reconcile_rollout;
|
||||
pub use codex_rollout::state_db::touch_thread_updated_at;
|
||||
pub use codex_state::LogEntry;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn get_state_db(config: &Config) -> Option<StateDbHandle> {
|
||||
rollout_state_db::get_state_db(config).await
|
||||
}
|
||||
+1
-1
@@ -69,5 +69,5 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "truncation_tests.rs"]
|
||||
#[path = "thread_rollout_truncation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -27,11 +27,11 @@ use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::formatted_truncate_text_content_items_with_policy;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
use crate::unified_exec::resolve_max_tokens;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
|
||||
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
|
||||
|
||||
pub(crate) use execute_handler::CodeModeExecuteHandler;
|
||||
use response_adapter::into_function_call_output_content_items;
|
||||
|
||||
@@ -4,8 +4,6 @@ use crate::codex::TurnContext;
|
||||
use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES;
|
||||
use crate::tools::TELEMETRY_PREVIEW_MAX_LINES;
|
||||
use crate::tools::TELEMETRY_PREVIEW_TRUNCATION_NOTICE;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::formatted_truncate_text;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::unified_exec::resolve_max_tokens;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
@@ -16,6 +14,8 @@ use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
@@ -42,12 +42,12 @@ use crate::original_image_detail::normalize_output_image_detail;
|
||||
use crate::sandboxing::ExecOptions;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformRequest;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
|
||||
pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:";
|
||||
const KERNEL_SOURCE: &str = include_str!("kernel.js");
|
||||
|
||||
@@ -15,9 +15,9 @@ pub mod sandboxing;
|
||||
pub mod spec;
|
||||
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::formatted_truncate_text;
|
||||
use crate::truncate::truncate_text;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
pub use router::ToolRouter;
|
||||
use serde::Serialize;
|
||||
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Utilities for truncating large chunks of output while preserving a prefix
|
||||
//! and suffix on UTF-8 boundaries, and helpers for line/token‑based truncation
|
||||
//! used across the core crate.
|
||||
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::openai_models::TruncationMode;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::protocol::TruncationPolicy as ProtocolTruncationPolicy;
|
||||
|
||||
const APPROX_BYTES_PER_TOKEN: usize = 4;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TruncationPolicy {
|
||||
Bytes(usize),
|
||||
Tokens(usize),
|
||||
}
|
||||
|
||||
impl From<TruncationPolicy> for ProtocolTruncationPolicy {
|
||||
fn from(value: TruncationPolicy) -> Self {
|
||||
match value {
|
||||
TruncationPolicy::Bytes(bytes) => Self::Bytes(bytes),
|
||||
TruncationPolicy::Tokens(tokens) => Self::Tokens(tokens),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TruncationPolicyConfig> for TruncationPolicy {
|
||||
fn from(config: TruncationPolicyConfig) -> Self {
|
||||
match config.mode {
|
||||
TruncationMode::Bytes => Self::Bytes(config.limit as usize),
|
||||
TruncationMode::Tokens => Self::Tokens(config.limit as usize),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TruncationPolicy {
|
||||
/// Returns a token budget derived from this policy.
|
||||
///
|
||||
/// - For `Tokens`, this is the explicit token limit.
|
||||
/// - For `Bytes`, this is an approximate token budget using the global
|
||||
/// bytes-per-token heuristic.
|
||||
pub fn token_budget(&self) -> usize {
|
||||
match self {
|
||||
TruncationPolicy::Bytes(bytes) => {
|
||||
usize::try_from(approx_tokens_from_byte_count(*bytes)).unwrap_or(usize::MAX)
|
||||
}
|
||||
TruncationPolicy::Tokens(tokens) => *tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a byte budget derived from this policy.
|
||||
///
|
||||
/// - For `Bytes`, this is the explicit byte limit.
|
||||
/// - For `Tokens`, this is an approximate byte budget using the global
|
||||
/// bytes-per-token heuristic.
|
||||
pub fn byte_budget(&self) -> usize {
|
||||
match self {
|
||||
TruncationPolicy::Bytes(bytes) => *bytes,
|
||||
TruncationPolicy::Tokens(tokens) => approx_bytes_for_tokens(*tokens),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Mul<f64> for TruncationPolicy {
|
||||
type Output = Self;
|
||||
|
||||
fn mul(self, multiplier: f64) -> Self::Output {
|
||||
match self {
|
||||
TruncationPolicy::Bytes(bytes) => {
|
||||
TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize)
|
||||
}
|
||||
TruncationPolicy::Tokens(tokens) => {
|
||||
TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> String {
|
||||
if content.len() <= policy.byte_budget() {
|
||||
return content.to_string();
|
||||
}
|
||||
let total_lines = content.lines().count();
|
||||
let result = truncate_text(content, policy);
|
||||
format!("Total output lines: {total_lines}\n\n{result}")
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_text(content: &str, policy: TruncationPolicy) -> String {
|
||||
match policy {
|
||||
TruncationPolicy::Bytes(_) => truncate_with_byte_estimate(content, policy),
|
||||
TruncationPolicy::Tokens(_) => {
|
||||
let (truncated, _) = truncate_with_token_budget(content, policy);
|
||||
truncated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn formatted_truncate_text_content_items_with_policy(
|
||||
items: &[FunctionCallOutputContentItem],
|
||||
policy: TruncationPolicy,
|
||||
) -> (Vec<FunctionCallOutputContentItem>, Option<usize>) {
|
||||
let text_segments = items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
FunctionCallOutputContentItem::InputText { text } => Some(text.as_str()),
|
||||
FunctionCallOutputContentItem::InputImage { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if text_segments.is_empty() {
|
||||
return (items.to_vec(), None);
|
||||
}
|
||||
|
||||
let mut combined = String::new();
|
||||
for text in &text_segments {
|
||||
if !combined.is_empty() {
|
||||
combined.push('\n');
|
||||
}
|
||||
combined.push_str(text);
|
||||
}
|
||||
|
||||
if combined.len() <= policy.byte_budget() {
|
||||
return (items.to_vec(), None);
|
||||
}
|
||||
|
||||
let mut out = vec![FunctionCallOutputContentItem::InputText {
|
||||
text: formatted_truncate_text(&combined, policy),
|
||||
}];
|
||||
out.extend(items.iter().filter_map(|item| match item {
|
||||
FunctionCallOutputContentItem::InputImage { image_url, detail } => {
|
||||
Some(FunctionCallOutputContentItem::InputImage {
|
||||
image_url: image_url.clone(),
|
||||
detail: *detail,
|
||||
})
|
||||
}
|
||||
FunctionCallOutputContentItem::InputText { .. } => None,
|
||||
}));
|
||||
|
||||
(out, Some(approx_token_count(&combined)))
|
||||
}
|
||||
|
||||
/// Globally truncate function output items to fit within the given
|
||||
/// truncation policy's budget, preserving as many text/image items as
|
||||
/// possible and appending a summary for any omitted text items.
|
||||
pub(crate) fn truncate_function_output_items_with_policy(
|
||||
items: &[FunctionCallOutputContentItem],
|
||||
policy: TruncationPolicy,
|
||||
) -> Vec<FunctionCallOutputContentItem> {
|
||||
let mut out: Vec<FunctionCallOutputContentItem> = Vec::with_capacity(items.len());
|
||||
let mut remaining_budget = match policy {
|
||||
TruncationPolicy::Bytes(_) => policy.byte_budget(),
|
||||
TruncationPolicy::Tokens(_) => policy.token_budget(),
|
||||
};
|
||||
let mut omitted_text_items = 0usize;
|
||||
|
||||
for it in items {
|
||||
match it {
|
||||
FunctionCallOutputContentItem::InputText { text } => {
|
||||
if remaining_budget == 0 {
|
||||
omitted_text_items += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let cost = match policy {
|
||||
TruncationPolicy::Bytes(_) => text.len(),
|
||||
TruncationPolicy::Tokens(_) => approx_token_count(text),
|
||||
};
|
||||
|
||||
if cost <= remaining_budget {
|
||||
out.push(FunctionCallOutputContentItem::InputText { text: text.clone() });
|
||||
remaining_budget = remaining_budget.saturating_sub(cost);
|
||||
} else {
|
||||
let snippet_policy = match policy {
|
||||
TruncationPolicy::Bytes(_) => TruncationPolicy::Bytes(remaining_budget),
|
||||
TruncationPolicy::Tokens(_) => TruncationPolicy::Tokens(remaining_budget),
|
||||
};
|
||||
let snippet = truncate_text(text, snippet_policy);
|
||||
if snippet.is_empty() {
|
||||
omitted_text_items += 1;
|
||||
} else {
|
||||
out.push(FunctionCallOutputContentItem::InputText { text: snippet });
|
||||
}
|
||||
remaining_budget = 0;
|
||||
}
|
||||
}
|
||||
FunctionCallOutputContentItem::InputImage { image_url, detail } => {
|
||||
out.push(FunctionCallOutputContentItem::InputImage {
|
||||
image_url: image_url.clone(),
|
||||
detail: *detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if omitted_text_items > 0 {
|
||||
out.push(FunctionCallOutputContentItem::InputText {
|
||||
text: format!("[omitted {omitted_text_items} text items ...]"),
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Truncate the middle of a UTF-8 string to at most `max_tokens` tokens,
|
||||
/// preserving the beginning and the end. Returns the possibly truncated string
|
||||
/// and `Some(original_token_count)` if truncation occurred; otherwise returns
|
||||
/// the original string and `None`.
|
||||
fn truncate_with_token_budget(s: &str, policy: TruncationPolicy) -> (String, Option<u64>) {
|
||||
if s.is_empty() {
|
||||
return (String::new(), None);
|
||||
}
|
||||
let max_tokens = policy.token_budget();
|
||||
|
||||
let byte_len = s.len();
|
||||
if max_tokens > 0 && byte_len <= approx_bytes_for_tokens(max_tokens) {
|
||||
return (s.to_string(), None);
|
||||
}
|
||||
|
||||
let truncated = truncate_with_byte_estimate(s, policy);
|
||||
let approx_total_usize = approx_token_count(s);
|
||||
let approx_total = u64::try_from(approx_total_usize).unwrap_or(u64::MAX);
|
||||
if truncated == s {
|
||||
(truncated, None)
|
||||
} else {
|
||||
(truncated, Some(approx_total))
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string using a byte budget derived from the token budget, without
|
||||
/// performing any real tokenization. This keeps the logic purely byte-based and
|
||||
/// uses a bytes placeholder in the truncated output.
|
||||
fn truncate_with_byte_estimate(s: &str, policy: TruncationPolicy) -> String {
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let total_chars = s.chars().count();
|
||||
let max_bytes = policy.byte_budget();
|
||||
|
||||
if max_bytes == 0 {
|
||||
// No budget to show content; just report that everything was truncated.
|
||||
let marker = format_truncation_marker(
|
||||
policy,
|
||||
removed_units_for_source(policy, s.len(), total_chars),
|
||||
);
|
||||
return marker;
|
||||
}
|
||||
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let total_bytes = s.len();
|
||||
|
||||
let (left_budget, right_budget) = split_budget(max_bytes);
|
||||
|
||||
let (removed_chars, left, right) = split_string(s, left_budget, right_budget);
|
||||
|
||||
let marker = format_truncation_marker(
|
||||
policy,
|
||||
removed_units_for_source(policy, total_bytes.saturating_sub(max_bytes), removed_chars),
|
||||
);
|
||||
|
||||
assemble_truncated_output(left, right, &marker)
|
||||
}
|
||||
|
||||
fn split_string(s: &str, beginning_bytes: usize, end_bytes: usize) -> (usize, &str, &str) {
|
||||
if s.is_empty() {
|
||||
return (0, "", "");
|
||||
}
|
||||
|
||||
let len = s.len();
|
||||
let tail_start_target = len.saturating_sub(end_bytes);
|
||||
let mut prefix_end = 0usize;
|
||||
let mut suffix_start = len;
|
||||
let mut removed_chars = 0usize;
|
||||
let mut suffix_started = false;
|
||||
|
||||
for (idx, ch) in s.char_indices() {
|
||||
let char_end = idx + ch.len_utf8();
|
||||
if char_end <= beginning_bytes {
|
||||
prefix_end = char_end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if idx >= tail_start_target {
|
||||
if !suffix_started {
|
||||
suffix_start = idx;
|
||||
suffix_started = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
removed_chars = removed_chars.saturating_add(1);
|
||||
}
|
||||
|
||||
if suffix_start < prefix_end {
|
||||
suffix_start = prefix_end;
|
||||
}
|
||||
|
||||
let before = &s[..prefix_end];
|
||||
let after = &s[suffix_start..];
|
||||
|
||||
(removed_chars, before, after)
|
||||
}
|
||||
|
||||
fn format_truncation_marker(policy: TruncationPolicy, removed_count: u64) -> String {
|
||||
match policy {
|
||||
TruncationPolicy::Tokens(_) => format!("…{removed_count} tokens truncated…"),
|
||||
TruncationPolicy::Bytes(_) => format!("…{removed_count} chars truncated…"),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_budget(budget: usize) -> (usize, usize) {
|
||||
let left = budget / 2;
|
||||
(left, budget - left)
|
||||
}
|
||||
|
||||
fn removed_units_for_source(
|
||||
policy: TruncationPolicy,
|
||||
removed_bytes: usize,
|
||||
removed_chars: usize,
|
||||
) -> u64 {
|
||||
match policy {
|
||||
TruncationPolicy::Tokens(_) => approx_tokens_from_byte_count(removed_bytes),
|
||||
TruncationPolicy::Bytes(_) => u64::try_from(removed_chars).unwrap_or(u64::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble_truncated_output(prefix: &str, suffix: &str, marker: &str) -> String {
|
||||
let mut out = String::with_capacity(prefix.len() + marker.len() + suffix.len() + 1);
|
||||
out.push_str(prefix);
|
||||
out.push_str(marker);
|
||||
out.push_str(suffix);
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn approx_token_count(text: &str) -> usize {
|
||||
let len = text.len();
|
||||
len.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN
|
||||
}
|
||||
|
||||
pub(crate) fn approx_bytes_for_tokens(tokens: usize) -> usize {
|
||||
tokens.saturating_mul(APPROX_BYTES_PER_TOKEN)
|
||||
}
|
||||
|
||||
pub(crate) fn approx_tokens_from_byte_count(bytes: usize) -> u64 {
|
||||
let bytes_u64 = bytes as u64;
|
||||
bytes_u64.saturating_add((APPROX_BYTES_PER_TOKEN as u64).saturating_sub(1))
|
||||
/ (APPROX_BYTES_PER_TOKEN as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn approx_tokens_from_byte_count_i64(bytes: i64) -> i64 {
|
||||
if bytes <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let bytes = usize::try_from(bytes).unwrap_or(usize::MAX);
|
||||
i64::try_from(approx_tokens_from_byte_count(bytes)).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "truncate_tests.rs"]
|
||||
mod tests;
|
||||
@@ -15,9 +15,9 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::exec::is_likely_sandbox_denied;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::formatted_truncate_text;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text;
|
||||
use codex_utils_pty::ExecCommandSession;
|
||||
use codex_utils_pty::SpawnedPty;
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ use crate::tools::orchestrator::ToolOrchestrator;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::unified_exec::ExecCommandRequest;
|
||||
use crate::unified_exec::MAX_UNIFIED_EXEC_PROCESSES;
|
||||
use crate::unified_exec::MAX_YIELD_TIME_MS;
|
||||
@@ -50,6 +49,7 @@ use crate::unified_exec::process::OutputBuffer;
|
||||
use crate::unified_exec::process::OutputHandles;
|
||||
use crate::unified_exec::process::SpawnLifecycleHandle;
|
||||
use crate::unified_exec::process::UnifiedExecProcess;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
|
||||
const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [
|
||||
("NO_COLOR", "1"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod path_utils;
|
||||
@@ -0,0 +1 @@
|
||||
pub use codex_utils_path::*;
|
||||
@@ -125,9 +125,8 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
|
||||
// GetHistory on fork1 flushed; the file is ready.
|
||||
let fork1_items = read_items(&fork1_path);
|
||||
assert!(fork1_items.len() > expected_after_first.len());
|
||||
pretty_assertions::assert_eq!(
|
||||
serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(),
|
||||
serde_json::to_value(&fork1_items).unwrap(),
|
||||
serde_json::to_value(&expected_after_first).unwrap()
|
||||
);
|
||||
|
||||
@@ -156,9 +155,8 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.unwrap_or(0);
|
||||
let expected_after_second: Vec<RolloutItem> = fork1_items[..cut_last_on_fork1].to_vec();
|
||||
let fork2_items = read_items(&fork2_path);
|
||||
assert!(fork2_items.len() > expected_after_second.len());
|
||||
pretty_assertions::assert_eq!(
|
||||
serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(),
|
||||
serde_json::to_value(&fork2_items).unwrap(),
|
||||
serde_json::to_value(&expected_after_second).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,14 +443,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
let body4 = req4.single_request().body_json();
|
||||
let input4 = body4["input"].as_array().expect("input array");
|
||||
let permissions_fork = permissions_texts(input4);
|
||||
assert_eq!(permissions_fork.len(), permissions_base.len() + 2);
|
||||
assert_eq!(permissions_fork.len(), permissions_base.len() + 1);
|
||||
assert_eq!(
|
||||
&permissions_fork[..permissions_base.len()],
|
||||
permissions_base.as_slice()
|
||||
);
|
||||
let new_permissions = &permissions_fork[permissions_base.len()..];
|
||||
assert_eq!(new_permissions.len(), 2);
|
||||
assert_eq!(new_permissions[0], new_permissions[1]);
|
||||
assert_eq!(new_permissions.len(), 1);
|
||||
assert_eq!(permissions_fork, permissions_resume);
|
||||
assert!(!permissions_base.contains(&new_permissions[0]));
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -16,6 +16,7 @@ codex-execpolicy = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-image = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
icu_decimal = { workspace = true }
|
||||
icu_locale_core = { workspace = true }
|
||||
icu_provider = { workspace = true, features = ["sync"] }
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::ops::Mul;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
@@ -2629,6 +2630,51 @@ pub enum TruncationPolicy {
|
||||
Tokens(usize),
|
||||
}
|
||||
|
||||
impl From<crate::openai_models::TruncationPolicyConfig> for TruncationPolicy {
|
||||
fn from(config: crate::openai_models::TruncationPolicyConfig) -> Self {
|
||||
match config.mode {
|
||||
crate::openai_models::TruncationMode::Bytes => Self::Bytes(config.limit as usize),
|
||||
crate::openai_models::TruncationMode::Tokens => Self::Tokens(config.limit as usize),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TruncationPolicy {
|
||||
pub fn token_budget(&self) -> usize {
|
||||
match self {
|
||||
TruncationPolicy::Bytes(bytes) => {
|
||||
usize::try_from(codex_utils_string::approx_tokens_from_byte_count(*bytes))
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
TruncationPolicy::Tokens(tokens) => *tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn byte_budget(&self) -> usize {
|
||||
match self {
|
||||
TruncationPolicy::Bytes(bytes) => *bytes,
|
||||
TruncationPolicy::Tokens(tokens) => {
|
||||
codex_utils_string::approx_bytes_for_tokens(*tokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for TruncationPolicy {
|
||||
type Output = Self;
|
||||
|
||||
fn mul(self, multiplier: f64) -> Self::Output {
|
||||
match self {
|
||||
TruncationPolicy::Bytes(bytes) => {
|
||||
TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize)
|
||||
}
|
||||
TruncationPolicy::Tokens(tokens) => {
|
||||
TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, JsonSchema)]
|
||||
pub struct RolloutLine {
|
||||
pub timestamp: String,
|
||||
|
||||
@@ -13,18 +13,13 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::env;
|
||||
#[cfg(windows)]
|
||||
use tracing::debug;
|
||||
|
||||
/// Resolves a program to its executable path on Unix systems.
|
||||
///
|
||||
/// Unix systems handle PATH resolution and script execution natively through
|
||||
/// the kernel's shebang (`#!`) mechanism, so this function simply returns
|
||||
/// the program name unchanged.
|
||||
#[cfg(unix)]
|
||||
pub fn resolve(program: OsString, _env: &HashMap<String, String>) -> std::io::Result<OsString> {
|
||||
pub fn resolve(program: OsString, _env: &HashMap<OsString, OsString>) -> std::io::Result<OsString> {
|
||||
Ok(program)
|
||||
}
|
||||
|
||||
@@ -38,25 +33,22 @@ pub fn resolve(program: OsString, _env: &HashMap<String, String>) -> std::io::Re
|
||||
/// This enables tools like `npx`, `pnpm`, and `yarn` to work correctly on Windows
|
||||
/// without requiring users to specify full paths or extensions in their configuration.
|
||||
#[cfg(windows)]
|
||||
pub fn resolve(program: OsString, env: &HashMap<String, String>) -> std::io::Result<OsString> {
|
||||
pub fn resolve(program: OsString, env: &HashMap<OsString, OsString>) -> std::io::Result<OsString> {
|
||||
// Get current directory for relative path resolution
|
||||
let cwd = env::current_dir()
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to get current directory: {e}")))?;
|
||||
|
||||
// Extract PATH from environment for search locations
|
||||
let search_path = env.get("PATH");
|
||||
let search_path = env.get(std::ffi::OsStr::new("PATH"));
|
||||
|
||||
// Attempt resolution via which crate
|
||||
match which::which_in(&program, search_path, &cwd) {
|
||||
Ok(resolved) => {
|
||||
debug!("Resolved {:?} to {:?}", program, resolved);
|
||||
tracing::debug!("Resolved {program:?} to {resolved:?}");
|
||||
Ok(resolved.into_os_string())
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Failed to resolve {:?}: {}. Using original path",
|
||||
program, e
|
||||
);
|
||||
tracing::debug!("Failed to resolve {program:?}: {e}. Using original path");
|
||||
// Fallback to original program - let Command::new() handle the error
|
||||
Ok(program)
|
||||
}
|
||||
@@ -146,7 +138,7 @@ mod tests {
|
||||
// Held to prevent the temporary directory from being deleted.
|
||||
_temp_dir: TempDir,
|
||||
program_name: String,
|
||||
mcp_env: HashMap<String, String>,
|
||||
mcp_env: HashMap<OsString, OsString>,
|
||||
}
|
||||
|
||||
impl TestExecutableEnv {
|
||||
@@ -160,10 +152,10 @@ mod tests {
|
||||
|
||||
// Build a clean environment with the temp dir in the PATH.
|
||||
let mut extra_env = HashMap::new();
|
||||
extra_env.insert("PATH".to_string(), Self::build_path(dir_path));
|
||||
extra_env.insert(OsString::from("PATH"), Self::build_path_env_var(dir_path));
|
||||
|
||||
#[cfg(windows)]
|
||||
extra_env.insert("PATHEXT".to_string(), Self::ensure_cmd_extension());
|
||||
extra_env.insert(OsString::from("PATHEXT"), Self::ensure_cmd_extension());
|
||||
|
||||
let mcp_env = create_env_for_mcp_server(Some(extra_env), &[]);
|
||||
|
||||
@@ -202,20 +194,30 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Prepends the given directory to the system's PATH variable.
|
||||
fn build_path(dir: &Path) -> String {
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
format!("{}{sep}{current}", dir.to_string_lossy())
|
||||
fn build_path_env_var(dir: &Path) -> OsString {
|
||||
let mut path = OsString::from(dir.as_os_str());
|
||||
if let Some(current) = std::env::var_os("PATH") {
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
path.push(sep);
|
||||
path.push(current);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Ensures `.CMD` is in the `PATHEXT` variable on Windows for script discovery.
|
||||
#[cfg(windows)]
|
||||
fn ensure_cmd_extension() -> String {
|
||||
let current = std::env::var("PATHEXT").unwrap_or_default();
|
||||
if current.to_uppercase().contains(".CMD") {
|
||||
fn ensure_cmd_extension() -> OsString {
|
||||
let current = std::env::var_os("PATHEXT").unwrap_or_default();
|
||||
if current
|
||||
.to_string_lossy()
|
||||
.to_ascii_uppercase()
|
||||
.contains(".CMD")
|
||||
{
|
||||
current
|
||||
} else {
|
||||
format!(".CMD;{current}")
|
||||
let mut path_ext = OsString::from(".CMD;");
|
||||
path_ext.push(current);
|
||||
path_ext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ enum TransportRecipe {
|
||||
Stdio {
|
||||
program: OsString,
|
||||
args: Vec<OsString>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
},
|
||||
@@ -478,7 +478,7 @@ impl RmcpClient {
|
||||
pub async fn new_stdio_client(
|
||||
program: OsString,
|
||||
args: Vec<OsString>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: &[String],
|
||||
cwd: Option<PathBuf>,
|
||||
) -> io::Result<Self> {
|
||||
|
||||
@@ -5,16 +5,17 @@ use reqwest::header::HeaderName;
|
||||
use reqwest::header::HeaderValue;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
|
||||
pub(crate) fn create_env_for_mcp_server(
|
||||
extra_env: Option<HashMap<String, String>>,
|
||||
extra_env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: &[String],
|
||||
) -> HashMap<String, String> {
|
||||
) -> HashMap<OsString, OsString> {
|
||||
DEFAULT_ENV_VARS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(env_vars.iter().map(String::as_str))
|
||||
.filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value)))
|
||||
.filter_map(|var| env::var_os(var).map(|value| (OsString::from(var), value)))
|
||||
.chain(extra_env.unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
@@ -140,7 +141,7 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: String,
|
||||
@@ -148,10 +149,10 @@ mod tests {
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &str, value: &str) -> Self {
|
||||
fn set(key: &str, value: impl AsRef<OsStr>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
std::env::set_var(key, value.as_ref());
|
||||
}
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
@@ -177,9 +178,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn create_env_honors_overrides() {
|
||||
let value = "custom".to_string();
|
||||
let env =
|
||||
create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]);
|
||||
assert_eq!(env.get("TZ"), Some(&value));
|
||||
let expected = OsString::from(&value);
|
||||
let env = create_env_for_mcp_server(
|
||||
Some(HashMap::from([(OsString::from("TZ"), expected.clone())])),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(env.get(OsStr::new("TZ")), Some(&expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -187,8 +191,24 @@ mod tests {
|
||||
fn create_env_includes_additional_whitelisted_variables() {
|
||||
let custom_var = "EXTRA_RMCP_ENV";
|
||||
let value = "from-env";
|
||||
let expected = OsString::from(value);
|
||||
let _guard = EnvVarGuard::set(custom_var, value);
|
||||
let env = create_env_for_mcp_server(None, &[custom_var.to_string()]);
|
||||
assert_eq!(env.get(custom_var), Some(&value.to_string()));
|
||||
assert_eq!(env.get(OsStr::new(custom_var)), Some(&expected));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial(extra_rmcp_env)]
|
||||
fn create_env_preserves_path_when_it_is_not_utf8() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let raw_path = std::ffi::OsStr::from_bytes(b"/tmp/codex-\xFF/bin");
|
||||
let expected = raw_path.to_os_string();
|
||||
let _guard = EnvVarGuard::set("PATH", raw_path);
|
||||
|
||||
let env = create_env_for_mcp_server(None, &[]);
|
||||
|
||||
assert_eq!(env.get(OsStr::new("PATH")), Some(&expected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ async fn drop_kills_wrapper_process_group() -> Result<()> {
|
||||
),
|
||||
],
|
||||
Some(HashMap::from([(
|
||||
"CHILD_PID_FILE".to_string(),
|
||||
child_pid_file_str,
|
||||
OsString::from("CHILD_PID_FILE"),
|
||||
OsString::from(child_pid_file_str),
|
||||
)])),
|
||||
&[],
|
||||
None,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "rollout",
|
||||
crate_name = "codex_rollout",
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-rollout"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_rollout"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
time = { workspace = true, features = [
|
||||
"formatting",
|
||||
"local-offset",
|
||||
"macros",
|
||||
"parsing",
|
||||
] }
|
||||
tokio = { workspace = true, features = [
|
||||
"fs",
|
||||
"io-util",
|
||||
"macros",
|
||||
"process",
|
||||
"rt",
|
||||
"sync",
|
||||
"time",
|
||||
] }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub trait RolloutConfigView {
|
||||
fn codex_home(&self) -> &Path;
|
||||
fn sqlite_home(&self) -> &Path;
|
||||
fn cwd(&self) -> &Path;
|
||||
fn model_provider_id(&self) -> &str;
|
||||
fn generate_memories(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RolloutConfig {
|
||||
pub codex_home: PathBuf,
|
||||
pub sqlite_home: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub model_provider_id: String,
|
||||
pub generate_memories: bool,
|
||||
}
|
||||
|
||||
pub type Config = RolloutConfig;
|
||||
|
||||
impl RolloutConfig {
|
||||
pub fn from_view(view: &impl RolloutConfigView) -> Self {
|
||||
Self {
|
||||
codex_home: view.codex_home().to_path_buf(),
|
||||
sqlite_home: view.sqlite_home().to_path_buf(),
|
||||
cwd: view.cwd().to_path_buf(),
|
||||
model_provider_id: view.model_provider_id().to_string(),
|
||||
generate_memories: view.generate_memories(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutConfigView for RolloutConfig {
|
||||
fn codex_home(&self) -> &Path {
|
||||
self.codex_home.as_path()
|
||||
}
|
||||
|
||||
fn sqlite_home(&self) -> &Path {
|
||||
self.sqlite_home.as_path()
|
||||
}
|
||||
|
||||
fn cwd(&self) -> &Path {
|
||||
self.cwd.as_path()
|
||||
}
|
||||
|
||||
fn model_provider_id(&self) -> &str {
|
||||
self.model_provider_id.as_str()
|
||||
}
|
||||
|
||||
fn generate_memories(&self) -> bool {
|
||||
self.generate_memories
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: RolloutConfigView + ?Sized> RolloutConfigView for &T {
|
||||
fn codex_home(&self) -> &Path {
|
||||
(*self).codex_home()
|
||||
}
|
||||
|
||||
fn sqlite_home(&self) -> &Path {
|
||||
(*self).sqlite_home()
|
||||
}
|
||||
|
||||
fn cwd(&self) -> &Path {
|
||||
(*self).cwd()
|
||||
}
|
||||
|
||||
fn model_provider_id(&self) -> &str {
|
||||
(*self).model_provider_id()
|
||||
}
|
||||
|
||||
fn generate_memories(&self) -> bool {
|
||||
(*self).generate_memories()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: RolloutConfigView + ?Sized> RolloutConfigView for Arc<T> {
|
||||
fn codex_home(&self) -> &Path {
|
||||
self.as_ref().codex_home()
|
||||
}
|
||||
|
||||
fn sqlite_home(&self) -> &Path {
|
||||
self.as_ref().sqlite_home()
|
||||
}
|
||||
|
||||
fn cwd(&self) -> &Path {
|
||||
self.as_ref().cwd()
|
||||
}
|
||||
|
||||
fn model_provider_id(&self) -> &str {
|
||||
self.as_ref().model_provider_id()
|
||||
}
|
||||
|
||||
fn generate_memories(&self) -> bool {
|
||||
self.as_ref().generate_memories()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
//! Rollout module: persistence and discovery of session rollout files.
|
||||
//! Rollout persistence and discovery for Codex session files.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
|
||||
pub mod config;
|
||||
pub mod list;
|
||||
pub mod metadata;
|
||||
pub mod policy;
|
||||
pub mod recorder;
|
||||
pub mod session_index;
|
||||
pub mod state_db;
|
||||
|
||||
pub(crate) mod default_client {
|
||||
pub use codex_login::default_client::*;
|
||||
}
|
||||
|
||||
pub(crate) use codex_protocol::protocol;
|
||||
|
||||
pub const SESSIONS_SUBDIR: &str = "sessions";
|
||||
pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
|
||||
pub static INTERACTIVE_SESSION_SOURCES: LazyLock<Vec<SessionSource>> = LazyLock::new(|| {
|
||||
@@ -15,26 +29,22 @@ pub static INTERACTIVE_SESSION_SOURCES: LazyLock<Vec<SessionSource>> = LazyLock:
|
||||
]
|
||||
});
|
||||
|
||||
pub(crate) mod error;
|
||||
pub mod list;
|
||||
pub(crate) mod metadata;
|
||||
pub(crate) mod policy;
|
||||
pub mod recorder;
|
||||
pub(crate) mod session_index;
|
||||
pub(crate) mod truncation;
|
||||
|
||||
pub use codex_protocol::protocol::SessionMeta;
|
||||
pub(crate) use error::map_session_init_error;
|
||||
pub use config::RolloutConfig;
|
||||
pub use config::RolloutConfigView;
|
||||
pub use list::find_archived_thread_path_by_id_str;
|
||||
pub use list::find_thread_path_by_id_str;
|
||||
#[deprecated(note = "use find_thread_path_by_id_str")]
|
||||
pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
|
||||
pub use list::rollout_date_parts;
|
||||
pub use policy::EventPersistenceMode;
|
||||
pub use recorder::RolloutRecorder;
|
||||
pub use recorder::RolloutRecorderParams;
|
||||
pub use session_index::append_thread_name;
|
||||
pub use session_index::find_thread_name_by_id;
|
||||
pub use session_index::find_thread_names_by_ids;
|
||||
pub use session_index::find_thread_path_by_name_str;
|
||||
pub use state_db::StateDbHandle;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
mod tests;
|
||||
@@ -1,7 +1,9 @@
|
||||
#![allow(warnings, clippy::all)]
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::cmp::Reverse;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::{self};
|
||||
use std::io;
|
||||
use std::num::NonZero;
|
||||
use std::ops::ControlFlow;
|
||||
use std::path::Path;
|
||||
@@ -111,16 +113,16 @@ pub enum ThreadSortKey {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ThreadListLayout {
|
||||
pub enum ThreadListLayout {
|
||||
NestedByDate,
|
||||
Flat,
|
||||
}
|
||||
|
||||
pub(crate) struct ThreadListConfig<'a> {
|
||||
pub(crate) allowed_sources: &'a [SessionSource],
|
||||
pub(crate) model_providers: Option<&'a [String]>,
|
||||
pub(crate) default_provider: &'a str,
|
||||
pub(crate) layout: ThreadListLayout,
|
||||
pub struct ThreadListConfig<'a> {
|
||||
pub allowed_sources: &'a [SessionSource],
|
||||
pub model_providers: Option<&'a [String]>,
|
||||
pub default_provider: &'a str,
|
||||
pub layout: ThreadListLayout,
|
||||
}
|
||||
|
||||
/// Pagination cursor identifying a file by timestamp and UUID.
|
||||
@@ -300,7 +302,7 @@ impl From<codex_state::Anchor> for Cursor {
|
||||
/// can be supplied on the next call to resume after the last returned item, resilient to
|
||||
/// concurrent new sessions being appended. Ordering is stable by the requested sort key
|
||||
/// (timestamp desc, then UUID desc).
|
||||
pub(crate) async fn get_threads(
|
||||
pub async fn get_threads(
|
||||
codex_home: &Path,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -325,7 +327,7 @@ pub(crate) async fn get_threads(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_threads_in_root(
|
||||
pub async fn get_threads_in_root(
|
||||
root: PathBuf,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::config::Config;
|
||||
use crate::rollout;
|
||||
use crate::rollout::list::parse_timestamp_uuid_from_filename;
|
||||
use crate::rollout::recorder::RolloutRecorder;
|
||||
use crate::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use crate::SESSIONS_SUBDIR;
|
||||
use crate::config::RolloutConfigView;
|
||||
use crate::list;
|
||||
use crate::list::parse_timestamp_uuid_from_filename;
|
||||
use crate::recorder::RolloutRecorder;
|
||||
use crate::state_db::normalize_cwd_for_state_db;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
@@ -62,7 +64,7 @@ pub(crate) fn builder_from_session_meta(
|
||||
Some(builder)
|
||||
}
|
||||
|
||||
pub(crate) fn builder_from_items(
|
||||
pub fn builder_from_items(
|
||||
items: &[RolloutItem],
|
||||
rollout_path: &Path,
|
||||
) -> Option<ThreadMetadataBuilder> {
|
||||
@@ -93,7 +95,7 @@ pub(crate) fn builder_from_items(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn extract_metadata_from_rollout(
|
||||
pub async fn extract_metadata_from_rollout(
|
||||
rollout_path: &Path,
|
||||
default_provider: &str,
|
||||
) -> anyhow::Result<ExtractionOutcome> {
|
||||
@@ -131,7 +133,10 @@ pub(crate) async fn extract_metadata_from_rollout(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, config: &Config) {
|
||||
pub(crate) async fn backfill_sessions(
|
||||
runtime: &codex_state::StateRuntime,
|
||||
config: &impl RolloutConfigView,
|
||||
) {
|
||||
let metric_client = codex_otel::metrics::global();
|
||||
let timer = metric_client
|
||||
.as_ref()
|
||||
@@ -141,7 +146,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to read backfill state at {}: {err}",
|
||||
config.codex_home.display()
|
||||
config.codex_home().display()
|
||||
);
|
||||
BackfillState::default()
|
||||
}
|
||||
@@ -154,7 +159,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to claim backfill worker at {}: {err}",
|
||||
config.codex_home.display()
|
||||
config.codex_home().display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +167,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
if !claimed {
|
||||
info!(
|
||||
"state db backfill already running at {}; skipping duplicate worker",
|
||||
config.codex_home.display()
|
||||
config.codex_home().display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +176,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to read claimed backfill state at {}: {err}",
|
||||
config.codex_home.display()
|
||||
config.codex_home().display()
|
||||
);
|
||||
BackfillState {
|
||||
status: BackfillStatus::Running,
|
||||
@@ -183,15 +188,15 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
if let Err(err) = runtime.mark_backfill_running().await {
|
||||
warn!(
|
||||
"failed to mark backfill running at {}: {err}",
|
||||
config.codex_home.display()
|
||||
config.codex_home().display()
|
||||
);
|
||||
} else {
|
||||
backfill_state.status = BackfillStatus::Running;
|
||||
}
|
||||
}
|
||||
|
||||
let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR);
|
||||
let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR);
|
||||
let sessions_root = config.codex_home().join(SESSIONS_SUBDIR);
|
||||
let archived_root = config.codex_home().join(ARCHIVED_SESSIONS_SUBDIR);
|
||||
let mut rollout_paths: Vec<BackfillRolloutPath> = Vec::new();
|
||||
for (root, archived) in [(sessions_root, false), (archived_root, true)] {
|
||||
if !tokio::fs::try_exists(&root).await.unwrap_or(false) {
|
||||
@@ -200,7 +205,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
match collect_rollout_paths(&root).await {
|
||||
Ok(paths) => {
|
||||
rollout_paths.extend(paths.into_iter().map(|path| BackfillRolloutPath {
|
||||
watermark: backfill_watermark_for_path(config.codex_home.as_path(), &path),
|
||||
watermark: backfill_watermark_for_path(config.codex_home(), &path),
|
||||
path,
|
||||
archived,
|
||||
}));
|
||||
@@ -227,9 +232,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
for batch in rollout_paths.chunks(BACKFILL_BATCH_SIZE) {
|
||||
for rollout in batch {
|
||||
stats.scanned = stats.scanned.saturating_add(1);
|
||||
match extract_metadata_from_rollout(&rollout.path, config.model_provider_id.as_str())
|
||||
.await
|
||||
{
|
||||
match extract_metadata_from_rollout(&rollout.path, config.model_provider_id()).await {
|
||||
Ok(outcome) => {
|
||||
if outcome.parse_errors > 0
|
||||
&& let Some(ref metric_client) = metric_client
|
||||
@@ -268,9 +271,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
continue;
|
||||
}
|
||||
stats.upserted = stats.upserted.saturating_add(1);
|
||||
if let Ok(meta_line) =
|
||||
rollout::list::read_session_meta_line(&rollout.path).await
|
||||
{
|
||||
if let Ok(meta_line) = list::read_session_meta_line(&rollout.path).await {
|
||||
if let Err(err) = runtime
|
||||
.persist_dynamic_tools(
|
||||
meta_line.meta.id,
|
||||
@@ -308,7 +309,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
{
|
||||
warn!(
|
||||
"failed to checkpoint backfill at {}: {err}",
|
||||
config.codex_home.display()
|
||||
config.codex_home().display()
|
||||
);
|
||||
} else {
|
||||
last_watermark = Some(last_entry.watermark.clone());
|
||||
@@ -321,7 +322,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi
|
||||
{
|
||||
warn!(
|
||||
"failed to mark backfill complete at {}: {err}",
|
||||
config.codex_home.display()
|
||||
config.codex_home().display()
|
||||
);
|
||||
}
|
||||
|
||||
+16
-9
@@ -1,4 +1,7 @@
|
||||
#![allow(warnings, clippy::all)]
|
||||
|
||||
use super::*;
|
||||
use crate::config::RolloutConfig;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Timelike;
|
||||
@@ -21,6 +24,16 @@ use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_config(codex_home: PathBuf) -> RolloutConfig {
|
||||
RolloutConfig {
|
||||
sqlite_home: codex_home.clone(),
|
||||
cwd: codex_home.clone(),
|
||||
codex_home,
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
generate_memories: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_metadata_from_rollout_uses_session_meta() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
@@ -197,9 +210,7 @@ async fn backfill_sessions_resumes_from_watermark_and_marks_complete() {
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut config = crate::config::test_config().await;
|
||||
config.codex_home = codex_home.clone();
|
||||
config.model_provider_id = "test-provider".to_string();
|
||||
let config = test_config(codex_home.clone());
|
||||
backfill_sessions(runtime.as_ref(), &config).await;
|
||||
|
||||
let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id");
|
||||
@@ -267,9 +278,7 @@ async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_f
|
||||
.await
|
||||
.expect("existing metadata upsert");
|
||||
|
||||
let mut config = crate::config::test_config().await;
|
||||
config.codex_home = codex_home.clone();
|
||||
config.model_provider_id = "test-provider".to_string();
|
||||
let config = test_config(codex_home.clone());
|
||||
backfill_sessions(runtime.as_ref(), &config).await;
|
||||
|
||||
let persisted = runtime
|
||||
@@ -304,9 +313,7 @@ async fn backfill_sessions_normalizes_cwd_before_upsert() {
|
||||
.await
|
||||
.expect("initialize runtime");
|
||||
|
||||
let mut config = crate::config::test_config().await;
|
||||
config.codex_home = codex_home.clone();
|
||||
config.model_provider_id = "test-provider".to_string();
|
||||
let config = test_config(codex_home.clone());
|
||||
backfill_sessions(runtime.as_ref(), &config).await;
|
||||
|
||||
let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
|
||||
@@ -11,8 +11,7 @@ pub enum EventPersistenceMode {
|
||||
|
||||
/// Whether a rollout `item` should be persisted in rollout files for the
|
||||
/// provided persistence `mode`.
|
||||
#[inline]
|
||||
pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool {
|
||||
pub fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
|
||||
RolloutItem::EventMsg(ev) => should_persist_event_msg(ev, mode),
|
||||
@@ -25,7 +24,7 @@ pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersiste
|
||||
|
||||
/// Whether a `ResponseItem` should be persisted in rollout files.
|
||||
#[inline]
|
||||
pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
|
||||
pub fn should_persist_response_item(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
@@ -46,7 +45,7 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
|
||||
|
||||
/// Whether a `ResponseItem` should be persisted for the memories.
|
||||
#[inline]
|
||||
pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool {
|
||||
pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, .. } => role != "developer",
|
||||
ResponseItem::LocalShellCall { .. }
|
||||
@@ -68,7 +67,7 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) ->
|
||||
/// Whether an `EventMsg` should be persisted in rollout files for the
|
||||
/// provided persistence `mode`.
|
||||
#[inline]
|
||||
pub(crate) fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool {
|
||||
pub fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool {
|
||||
match mode {
|
||||
EventPersistenceMode::Limited => should_persist_event_msg_limited(ev),
|
||||
EventPersistenceMode::Extended => should_persist_event_msg_extended(ev),
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later.
|
||||
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::{self};
|
||||
use std::io::Error as IoError;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -11,14 +11,15 @@ use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_utils_string::truncate_middle_chars;
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::FormatItem;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::macros::format_description;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::mpsc::{self};
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::info;
|
||||
use tracing::trace;
|
||||
@@ -39,13 +40,10 @@ use super::list::parse_timestamp_uuid_from_filename;
|
||||
use super::metadata;
|
||||
use super::policy::EventPersistenceMode;
|
||||
use super::policy::is_persisted_response_item;
|
||||
use crate::config::Config;
|
||||
use crate::config::RolloutConfigView;
|
||||
use crate::default_client::originator;
|
||||
use crate::path_utils;
|
||||
use crate::state_db;
|
||||
use crate::state_db::StateDbHandle;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use codex_git_utils::collect_git_info;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GitInfo as ProtocolGitInfo;
|
||||
@@ -58,6 +56,7 @@ use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_state::StateRuntime;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use codex_utils_path as path_utils;
|
||||
|
||||
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
|
||||
/// every update.
|
||||
@@ -146,9 +145,9 @@ fn sanitize_rollout_item_for_persistence(
|
||||
match item {
|
||||
RolloutItem::EventMsg(EventMsg::ExecCommandEnd(mut event)) => {
|
||||
// Persist only a bounded aggregated summary of command output.
|
||||
event.aggregated_output = truncate_text(
|
||||
event.aggregated_output = truncate_middle_chars(
|
||||
&event.aggregated_output,
|
||||
TruncationPolicy::Bytes(PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES),
|
||||
PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES,
|
||||
);
|
||||
// Drop unnecessary fields from rollout storage since aggregated_output is all we need.
|
||||
event.stdout.clear();
|
||||
@@ -164,7 +163,7 @@ impl RolloutRecorder {
|
||||
/// List threads (rollout files) under the provided Codex home directory.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_threads(
|
||||
config: &Config,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
@@ -190,7 +189,7 @@ impl RolloutRecorder {
|
||||
/// List archived threads (rollout files) under the archived sessions directory.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_archived_threads(
|
||||
config: &Config,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
@@ -215,7 +214,7 @@ impl RolloutRecorder {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn list_threads_with_db_fallback(
|
||||
config: &Config,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
@@ -225,7 +224,7 @@ impl RolloutRecorder {
|
||||
archived: bool,
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
let codex_home = config.codex_home.as_path();
|
||||
let codex_home = config.codex_home();
|
||||
// Filesystem-first listing intentionally overfetches so we can repair stale/missing
|
||||
// SQLite rollout paths before the final DB-backed page is returned.
|
||||
let fs_page_size = page_size.saturating_mul(2).max(page_size);
|
||||
@@ -299,7 +298,7 @@ impl RolloutRecorder {
|
||||
/// Find the newest recorded thread path, optionally filtering to a matching cwd.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn find_latest_thread_path(
|
||||
config: &Config,
|
||||
config: &impl RolloutConfigView,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
@@ -308,7 +307,7 @@ impl RolloutRecorder {
|
||||
default_provider: &str,
|
||||
filter_cwd: Option<&Path>,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
let codex_home = config.codex_home.as_path();
|
||||
let codex_home = config.codex_home();
|
||||
let state_db_ctx = state_db::get_state_db(config).await;
|
||||
if state_db_ctx.is_some() {
|
||||
let mut db_cursor = cursor.cloned();
|
||||
@@ -369,7 +368,7 @@ impl RolloutRecorder {
|
||||
///
|
||||
/// For resumed sessions, this immediately opens the existing rollout file.
|
||||
pub async fn new(
|
||||
config: &Config,
|
||||
config: &impl RolloutConfigView,
|
||||
params: RolloutRecorderParams,
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
state_builder: Option<ThreadMetadataBuilder>,
|
||||
@@ -401,21 +400,21 @@ impl RolloutRecorder {
|
||||
id: session_id,
|
||||
forked_from_id,
|
||||
timestamp,
|
||||
cwd: config.cwd.clone(),
|
||||
cwd: config.cwd().to_path_buf(),
|
||||
originator: originator().value,
|
||||
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
agent_nickname: source.get_nickname(),
|
||||
agent_role: source.get_agent_role(),
|
||||
agent_path: source.get_agent_path().map(Into::into),
|
||||
source,
|
||||
model_provider: Some(config.model_provider_id.clone()),
|
||||
model_provider: Some(config.model_provider_id().to_string()),
|
||||
base_instructions: Some(base_instructions),
|
||||
dynamic_tools: if dynamic_tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(dynamic_tools)
|
||||
},
|
||||
memory_mode: (!config.memories.generate_memories)
|
||||
memory_mode: (!config.generate_memories())
|
||||
.then_some("disabled".to_string()),
|
||||
};
|
||||
|
||||
@@ -445,7 +444,7 @@ impl RolloutRecorder {
|
||||
};
|
||||
|
||||
// Clone the cwd for the spawned task to collect git info asynchronously
|
||||
let cwd = config.cwd.clone();
|
||||
let cwd = config.cwd().to_path_buf();
|
||||
|
||||
// A reasonably-sized bounded channel. If the buffer fills up the send
|
||||
// future will yield, which is fine – we only need to ensure we do not
|
||||
@@ -463,8 +462,8 @@ impl RolloutRecorder {
|
||||
rollout_path.clone(),
|
||||
state_db_ctx.clone(),
|
||||
state_builder,
|
||||
config.model_provider_id.clone(),
|
||||
config.memories.generate_memories,
|
||||
config.model_provider_id().to_string(),
|
||||
config.generate_memories(),
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
@@ -483,7 +482,7 @@ impl RolloutRecorder {
|
||||
self.state_db.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> {
|
||||
pub async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> {
|
||||
let mut filtered = Vec::new();
|
||||
for item in items {
|
||||
// Note that function calls may look a bit strange if they are
|
||||
@@ -529,7 +528,7 @@ impl RolloutRecorder {
|
||||
.map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}")))
|
||||
}
|
||||
|
||||
pub(crate) async fn load_rollout_items(
|
||||
pub async fn load_rollout_items(
|
||||
path: &Path,
|
||||
) -> std::io::Result<(Vec<RolloutItem>, Option<ThreadId>, usize)> {
|
||||
trace!("Resuming rollout from {path:?}");
|
||||
@@ -661,13 +660,13 @@ struct LogFileInfo {
|
||||
}
|
||||
|
||||
fn precompute_log_file_info(
|
||||
config: &Config,
|
||||
config: &impl RolloutConfigView,
|
||||
conversation_id: ThreadId,
|
||||
) -> std::io::Result<LogFileInfo> {
|
||||
// Resolve ~/.codex/sessions/YYYY/MM/DD path.
|
||||
let timestamp = OffsetDateTime::now_local()
|
||||
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
|
||||
let mut dir = config.codex_home.clone();
|
||||
let mut dir = config.codex_home().to_path_buf();
|
||||
dir.push(SESSIONS_SUBDIR);
|
||||
dir.push(timestamp.year().to_string());
|
||||
dir.push(format!("{:02}", u8::from(timestamp.month())));
|
||||
+20
-47
@@ -1,7 +1,8 @@
|
||||
#![allow(warnings, clippy::all)]
|
||||
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::RolloutConfig;
|
||||
use chrono::TimeZone;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -10,8 +11,8 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::{self};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -19,6 +20,16 @@ use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_config(codex_home: &Path) -> RolloutConfig {
|
||||
RolloutConfig {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
sqlite_home: codex_home.to_path_buf(),
|
||||
cwd: codex_home.to_path_buf(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
generate_memories: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<PathBuf> {
|
||||
let day_dir = root.join("sessions/2025/01/03");
|
||||
fs::create_dir_all(&day_dir)?;
|
||||
@@ -54,10 +65,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<Path
|
||||
#[tokio::test]
|
||||
async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
let config = test_config(home.path());
|
||||
let thread_id = ThreadId::new();
|
||||
let recorder = RolloutRecorder::new(
|
||||
&config,
|
||||
@@ -141,14 +149,7 @@ async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<
|
||||
#[tokio::test]
|
||||
async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
let config = test_config(home.path());
|
||||
|
||||
let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone())
|
||||
.await
|
||||
@@ -229,14 +230,7 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu
|
||||
async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()>
|
||||
{
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
let config = test_config(home.path());
|
||||
|
||||
let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone())
|
||||
.await
|
||||
@@ -280,14 +274,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() ->
|
||||
#[tokio::test]
|
||||
async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.disable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite to be disabled");
|
||||
let config = test_config(home.path());
|
||||
|
||||
let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?;
|
||||
let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?;
|
||||
@@ -328,14 +315,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
|
||||
#[tokio::test]
|
||||
async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
let config = test_config(home.path());
|
||||
|
||||
let uuid = Uuid::from_u128(9010);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
@@ -396,14 +376,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul
|
||||
#[tokio::test]
|
||||
async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
let config = test_config(home.path());
|
||||
|
||||
let uuid = Uuid::from_u128(9011);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
#![allow(warnings, clippy::all)]
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::config::Config;
|
||||
use crate::path_utils::normalize_for_path_comparison;
|
||||
use crate::rollout::list::Cursor;
|
||||
use crate::rollout::list::ThreadSortKey;
|
||||
use crate::rollout::metadata;
|
||||
use crate::config::RolloutConfig;
|
||||
use crate::config::RolloutConfigView;
|
||||
use crate::list::Cursor;
|
||||
use crate::list::ThreadSortKey;
|
||||
use crate::metadata;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Timelike;
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
pub use codex_state::LogEntry;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use codex_utils_path::normalize_for_path_comparison;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -23,9 +24,9 @@ use uuid::Uuid;
|
||||
/// Core-facing handle to the SQLite-backed state runtime.
|
||||
pub type StateDbHandle = Arc<codex_state::StateRuntime>;
|
||||
|
||||
/// Initialize the state runtime for thread state persistence and backfill checks. To only be used
|
||||
/// inside `core`. The initialization should not be done anywhere else.
|
||||
pub(crate) async fn init(config: &Config) -> Option<StateDbHandle> {
|
||||
/// Initialize the state runtime for thread state persistence and backfill checks.
|
||||
pub async fn init(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
|
||||
let config = RolloutConfig::from_view(config);
|
||||
let runtime = match codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.model_provider_id.clone(),
|
||||
@@ -62,18 +63,18 @@ pub(crate) async fn init(config: &Config) -> Option<StateDbHandle> {
|
||||
}
|
||||
|
||||
/// Get the DB if the feature is enabled and the DB exists.
|
||||
pub async fn get_state_db(config: &Config) -> Option<StateDbHandle> {
|
||||
let state_path = codex_state::state_db_path(config.sqlite_home.as_path());
|
||||
pub async fn get_state_db(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
|
||||
let state_path = codex_state::state_db_path(config.sqlite_home());
|
||||
if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.model_provider_id.clone(),
|
||||
config.sqlite_home().to_path_buf(),
|
||||
config.model_provider_id().to_string(),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
require_backfill_complete(runtime, config.sqlite_home.as_path()).await
|
||||
require_backfill_complete(runtime, config.sqlite_home()).await
|
||||
}
|
||||
|
||||
/// Open the state runtime when the SQLite file exists, without feature gating.
|
||||
@@ -135,7 +136,7 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option<codex_state::Anchor> {
|
||||
Some(codex_state::Anchor { ts, id })
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf {
|
||||
pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf {
|
||||
normalize_for_path_comparison(cwd).unwrap_or_else(|_| cwd.to_path_buf())
|
||||
}
|
||||
|
||||
@@ -398,7 +399,7 @@ pub async fn reconcile_rollout(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Ok(meta_line) = crate::rollout::list::read_session_meta_line(rollout_path).await {
|
||||
if let Ok(meta_line) = crate::list::read_session_meta_line(rollout_path).await {
|
||||
persist_dynamic_tools(
|
||||
Some(ctx),
|
||||
meta_line.meta.id,
|
||||
@@ -463,7 +464,7 @@ pub async fn read_repair_rollout_path(
|
||||
if !saw_existing_metadata {
|
||||
warn!("state db discrepancy during read_repair_rollout_path: upsert_needed (slow path)");
|
||||
}
|
||||
let default_provider = crate::rollout::list::read_session_meta_line(rollout_path)
|
||||
let default_provider = crate::list::read_session_meta_line(rollout_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|meta| meta.meta.model_provider)
|
||||
@@ -1,6 +1,13 @@
|
||||
#![allow(warnings, clippy::all)]
|
||||
|
||||
use super::*;
|
||||
use crate::rollout::list::parse_cursor;
|
||||
use crate::list::parse_cursor;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Timelike;
|
||||
use chrono::Utc;
|
||||
use pretty_assertions::assert_eq;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn cursor_to_anchor_normalizes_timestamp_format() {
|
||||
@@ -1,9 +1,10 @@
|
||||
#![allow(warnings, clippy::all)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::{self};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -17,14 +18,15 @@ use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
use crate::rollout::list::Cursor;
|
||||
use crate::rollout::list::ThreadItem;
|
||||
use crate::rollout::list::ThreadSortKey;
|
||||
use crate::rollout::list::ThreadsPage;
|
||||
use crate::rollout::list::get_threads;
|
||||
use crate::rollout::list::read_head_for_summary;
|
||||
use crate::rollout::rollout_date_parts;
|
||||
use crate::INTERACTIVE_SESSION_SOURCES;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
use crate::list::Cursor;
|
||||
use crate::list::ThreadItem;
|
||||
use crate::list::ThreadSortKey;
|
||||
use crate::list::ThreadsPage;
|
||||
use crate::list::get_threads;
|
||||
use crate::list::read_head_for_summary;
|
||||
use crate::rollout_date_parts;
|
||||
use anyhow::Result;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -229,7 +231,7 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() {
|
||||
));
|
||||
insert_state_db_thread(home, thread_id, stale_db_path.as_path(), false).await;
|
||||
|
||||
let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string())
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string())
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert_eq!(found, Some(fs_rollout_path.clone()));
|
||||
@@ -255,7 +257,7 @@ async fn find_thread_path_repairs_missing_db_row_after_filesystem_fallback() {
|
||||
.await
|
||||
.expect("backfill should be complete");
|
||||
|
||||
let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string())
|
||||
let found = find_thread_path_by_id_str(home, &uuid.to_string())
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert_eq!(found, Some(fs_rollout_path.clone()));
|
||||
@@ -3584,41 +3584,8 @@ impl ChatComposer {
|
||||
|
||||
fn mention_items(&self) -> Vec<MentionItem> {
|
||||
let mut mentions = Vec::new();
|
||||
let plugin_namespaces: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| {
|
||||
let (plugin_name, _) = plugin
|
||||
.config_name
|
||||
.split_once('@')
|
||||
.unwrap_or((plugin.config_name.as_str(), ""));
|
||||
let plugin_name = plugin_name.trim();
|
||||
if plugin_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(plugin_name.to_ascii_lowercase())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let plugin_display_names: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
.iter()
|
||||
.map(|plugin| plugin.display_name.to_ascii_lowercase())
|
||||
.collect()
|
||||
});
|
||||
|
||||
if let Some(skills) = self.skills.as_ref() {
|
||||
for skill in skills {
|
||||
let is_plugin_namespaced_skill =
|
||||
skill.name.split_once(':').is_some_and(|(namespace, _)| {
|
||||
plugin_namespaces.contains(&namespace.to_ascii_lowercase())
|
||||
});
|
||||
if is_plugin_namespaced_skill {
|
||||
continue;
|
||||
}
|
||||
let display_name = skill_display_name(skill).to_string();
|
||||
let description = skill_description(skill);
|
||||
let skill_name = skill.name.clone();
|
||||
@@ -3698,30 +3665,18 @@ impl ChatComposer {
|
||||
if !connector.is_accessible || !connector.is_enabled {
|
||||
continue;
|
||||
}
|
||||
let plugin_backed_connector = connector
|
||||
.plugin_display_names
|
||||
.iter()
|
||||
.any(|name| plugin_display_names.contains(&name.to_ascii_lowercase()));
|
||||
if plugin_backed_connector {
|
||||
continue;
|
||||
}
|
||||
let display_name = connectors::connector_display_label(connector);
|
||||
let description = Some(Self::connector_brief_description(connector));
|
||||
let slug = codex_core::connectors::connector_mention_slug(connector);
|
||||
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
|
||||
let connector_id = connector.id.as_str();
|
||||
let category_tag = if connector.plugin_display_names.is_empty() {
|
||||
"[App]".to_string()
|
||||
} else {
|
||||
"[Plugin]".to_string()
|
||||
};
|
||||
mentions.push(MentionItem {
|
||||
display_name: display_name.clone(),
|
||||
description,
|
||||
insert_text: format!("${slug}"),
|
||||
search_terms,
|
||||
path: Some(format!("app://{connector_id}")),
|
||||
category_tag: Some(category_tag),
|
||||
category_tag: Some("[App]".to_string()),
|
||||
sort_rank: 1,
|
||||
});
|
||||
}
|
||||
@@ -5428,7 +5383,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_items_hide_plugin_owned_skill_and_app_duplicates() {
|
||||
fn mention_items_show_plugin_owned_skill_and_app_duplicates() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -5491,63 +5446,20 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mentions = composer.mention_items();
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions.len(), 3);
|
||||
assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string()));
|
||||
assert_eq!(
|
||||
mentions[0].path,
|
||||
Some("/tmp/repo/google-calendar/SKILL.md".to_string())
|
||||
);
|
||||
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
|
||||
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(
|
||||
mentions[0].path,
|
||||
Some("plugin://google-calendar@debug".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_items_hide_plugin_owned_app_duplicates_when_provenance_matches() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_connectors_enabled(true);
|
||||
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||||
config_name: "google-calendar@debug".to_string(),
|
||||
display_name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: vec!["google-calendar".to_string()],
|
||||
app_connector_ids: vec![codex_core::plugins::AppConnectorId(
|
||||
"google_calendar".to_string(),
|
||||
)],
|
||||
}]));
|
||||
composer.set_connector_mentions(Some(ConnectorsSnapshot {
|
||||
connectors: vec![AppInfo {
|
||||
id: "google_calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Look up events and availability".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/google-calendar".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: vec!["Google Calendar".to_string()],
|
||||
}],
|
||||
}));
|
||||
|
||||
let mentions = composer.mention_items();
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(
|
||||
mentions[0].path,
|
||||
mentions[1].path,
|
||||
Some("plugin://google-calendar@debug".to_string())
|
||||
);
|
||||
assert_eq!(mentions[2].category_tag, Some("[App]".to_string()));
|
||||
assert_eq!(mentions[2].path, Some("app://google_calendar".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
@@ -6,7 +8,11 @@ use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::history_cell;
|
||||
use crate::onboarding::mark_url_hyperlink;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
@@ -17,10 +23,101 @@ use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
|
||||
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
|
||||
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
struct DelayedLoadingHeader {
|
||||
started_at: Instant,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
loading_text: String,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
impl DelayedLoadingHeader {
|
||||
fn new(
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
loading_text: String,
|
||||
note: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
loading_text,
|
||||
note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for DelayedLoadingHeader {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(3);
|
||||
lines.push(Line::from("Plugins".bold()));
|
||||
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.started_at);
|
||||
if elapsed < LOADING_ANIMATION_DELAY {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed);
|
||||
lines.push(Line::from(self.loading_text.as_str().dim()));
|
||||
} else if self.animations_enabled {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
|
||||
lines.push(Line::from(shimmer_spans(self.loading_text.as_str())));
|
||||
} else {
|
||||
lines.push(Line::from(self.loading_text.as_str().dim()));
|
||||
}
|
||||
|
||||
if let Some(note) = &self.note {
|
||||
lines.push(Line::from(note.as_str().dim()));
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
2 + u16::from(self.note.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt";
|
||||
|
||||
struct PluginDisclosureLine {
|
||||
line: Line<'static>,
|
||||
}
|
||||
|
||||
impl Renderable for PluginDisclosureLine {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new(self.line.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(self.line.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(super) enum PluginsCacheState {
|
||||
@@ -474,16 +571,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from("Loading available plugins...".dim()));
|
||||
header.push(Line::from(
|
||||
"Available marketplaces will appear here when ready.".dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
header: Box::new(DelayedLoadingHeader::new(
|
||||
self.frame_requester.clone(),
|
||||
self.config.animations,
|
||||
"Loading available plugins...".to_string(),
|
||||
Some("This first pass shows the ChatGPT marketplace only.".to_string()),
|
||||
)),
|
||||
items: vec![SelectionItem {
|
||||
name: "Loading plugins...".to_string(),
|
||||
description: Some("This updates when the marketplace list is ready.".to_string()),
|
||||
@@ -495,15 +590,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("Loading details for {plugin_display_name}...").dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
header: Box::new(DelayedLoadingHeader::new(
|
||||
self.frame_requester.clone(),
|
||||
self.config.animations,
|
||||
format!("Loading details for {plugin_display_name}..."),
|
||||
/*note*/ None,
|
||||
)),
|
||||
items: vec![SelectionItem {
|
||||
name: "Loading plugin details...".to_string(),
|
||||
description: Some("This updates when plugin details load.".to_string()),
|
||||
@@ -744,13 +838,37 @@ impl ChatWidget {
|
||||
) -> SelectionViewParams {
|
||||
let marketplace_label = plugin.marketplace_name.clone();
|
||||
let display_name = plugin_display_name(&plugin.summary);
|
||||
let status_label = plugin_status_label(&plugin.summary);
|
||||
let detail_status_label = if plugin.summary.installed {
|
||||
if plugin.summary.enabled {
|
||||
"Installed"
|
||||
} else {
|
||||
"Installed · Disabled"
|
||||
}
|
||||
} else {
|
||||
match plugin.summary.install_policy {
|
||||
PluginInstallPolicy::NotAvailable => "Not installable",
|
||||
PluginInstallPolicy::Available => "Can be installed",
|
||||
PluginInstallPolicy::InstalledByDefault => "Available by default",
|
||||
}
|
||||
};
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("{display_name} · {marketplace_label}").bold(),
|
||||
format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(),
|
||||
));
|
||||
header.push(Line::from(status_label.dim()));
|
||||
if !plugin.summary.installed {
|
||||
header.push(PluginDisclosureLine {
|
||||
line: Line::from(vec![
|
||||
"Data shared with this app is subject to the app's ".into(),
|
||||
"terms of service".bold(),
|
||||
" and ".into(),
|
||||
"privacy policy".bold(),
|
||||
". ".into(),
|
||||
"Learn more".cyan().underlined(),
|
||||
".".into(),
|
||||
]),
|
||||
});
|
||||
}
|
||||
if let Some(description) = plugin_detail_description(plugin) {
|
||||
header.push(Line::from(description.dim()));
|
||||
}
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Figma · ChatGPT Marketplace
|
||||
Available
|
||||
Figma · Can be installed · ChatGPT Marketplace
|
||||
Data shared with this app is subject to the app's terms of service and privacy policy. Learn
|
||||
more.
|
||||
Turn Figma files into implementation context.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Figma · Installed · ChatGPT Marketplace
|
||||
Turn Figma files into implementation context.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Uninstall plugin Remove this plugin now.
|
||||
Skills design-review, extract-copy
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
+1
-1
@@ -4,6 +4,6 @@ expression: popup
|
||||
---
|
||||
Plugins
|
||||
Loading available plugins...
|
||||
Available marketplaces will appear here when ready.
|
||||
This first pass shows the ChatGPT marketplace only.
|
||||
|
||||
› Loading plugins... This updates when the marketplace list is ready.
|
||||
|
||||
@@ -7083,6 +7083,40 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn strip_osc8_for_snapshot(text: &str) -> String {
|
||||
// Snapshots should assert the visible popup text, not terminal hyperlink escapes.
|
||||
let bytes = text.as_bytes();
|
||||
let mut stripped = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
|
||||
while i < bytes.len() {
|
||||
if bytes[i..].starts_with(b"\x1B]8;;") {
|
||||
i += 5;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\x07' {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if i + 1 < bytes.len() && bytes[i] == b'\x1B' && bytes[i + 1] == b'\\' {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let ch = text[i..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("slice should always contain a char");
|
||||
stripped.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
|
||||
stripped
|
||||
}
|
||||
|
||||
fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
std::env::temp_dir()
|
||||
@@ -7338,7 +7372,54 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert_snapshot!("plugin_detail_popup_installable", popup);
|
||||
assert_snapshot!(
|
||||
"plugin_detail_popup_installable",
|
||||
strip_osc8_for_snapshot(&popup)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let summary = plugins_test_summary(
|
||||
"plugin-figma",
|
||||
"figma",
|
||||
Some("Figma"),
|
||||
Some("Design handoff."),
|
||||
true,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
);
|
||||
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
summary.clone(),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd.clone(), Ok(response));
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd,
|
||||
Ok(PluginReadResponse {
|
||||
plugin: plugins_test_detail(
|
||||
summary,
|
||||
Some("Turn Figma files into implementation context."),
|
||||
&["design-review", "extract-copy"],
|
||||
&[("Figma", true), ("Slack", false)],
|
||||
&["figma-mcp", "docs-mcp"],
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
!popup.contains("Data shared with this app is subject to the app's"),
|
||||
"expected installed plugin details to hide the disclosure line, got:\n{popup}"
|
||||
);
|
||||
assert_snapshot!(
|
||||
"plugin_detail_popup_installed",
|
||||
strip_osc8_for_snapshot(&popup)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod auth;
|
||||
pub mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
pub(crate) use auth::mark_url_hyperlink;
|
||||
pub use trust_directory::TrustDirectorySelection;
|
||||
mod welcome;
|
||||
|
||||
@@ -388,6 +388,7 @@ fn server_notification_thread_target(
|
||||
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
|
||||
| ServerNotification::CommandExecOutputDelta(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
| ServerNotification::WindowsSandboxSetupCompleted(_)
|
||||
| ServerNotification::AccountLoginCompleted(_) => None,
|
||||
|
||||
@@ -3599,41 +3599,8 @@ impl ChatComposer {
|
||||
|
||||
fn mention_items(&self) -> Vec<MentionItem> {
|
||||
let mut mentions = Vec::new();
|
||||
let plugin_namespaces: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| {
|
||||
let (plugin_name, _) = plugin
|
||||
.config_name
|
||||
.split_once('@')
|
||||
.unwrap_or((plugin.config_name.as_str(), ""));
|
||||
let plugin_name = plugin_name.trim();
|
||||
if plugin_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(plugin_name.to_ascii_lowercase())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let plugin_display_names: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
.iter()
|
||||
.map(|plugin| plugin.display_name.to_ascii_lowercase())
|
||||
.collect()
|
||||
});
|
||||
|
||||
if let Some(skills) = self.skills.as_ref() {
|
||||
for skill in skills {
|
||||
let is_plugin_namespaced_skill =
|
||||
skill.name.split_once(':').is_some_and(|(namespace, _)| {
|
||||
plugin_namespaces.contains(&namespace.to_ascii_lowercase())
|
||||
});
|
||||
if is_plugin_namespaced_skill {
|
||||
continue;
|
||||
}
|
||||
let display_name = skill_display_name(skill).to_string();
|
||||
let description = skill_description(skill);
|
||||
let skill_name = skill.name.clone();
|
||||
@@ -3713,30 +3680,18 @@ impl ChatComposer {
|
||||
if !connector.is_accessible || !connector.is_enabled {
|
||||
continue;
|
||||
}
|
||||
let plugin_backed_connector = connector
|
||||
.plugin_display_names
|
||||
.iter()
|
||||
.any(|name| plugin_display_names.contains(&name.to_ascii_lowercase()));
|
||||
if plugin_backed_connector {
|
||||
continue;
|
||||
}
|
||||
let display_name = connectors::connector_display_label(connector);
|
||||
let description = Some(Self::connector_brief_description(connector));
|
||||
let slug = codex_core::connectors::connector_mention_slug(connector);
|
||||
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
|
||||
let connector_id = connector.id.as_str();
|
||||
let category_tag = if connector.plugin_display_names.is_empty() {
|
||||
"[App]".to_string()
|
||||
} else {
|
||||
"[Plugin]".to_string()
|
||||
};
|
||||
mentions.push(MentionItem {
|
||||
display_name: display_name.clone(),
|
||||
description,
|
||||
insert_text: format!("${slug}"),
|
||||
search_terms,
|
||||
path: Some(format!("app://{connector_id}")),
|
||||
category_tag: Some(category_tag),
|
||||
category_tag: Some("[App]".to_string()),
|
||||
sort_rank: 1,
|
||||
});
|
||||
}
|
||||
@@ -5443,7 +5398,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_items_hide_plugin_owned_skill_and_app_duplicates() {
|
||||
fn mention_items_show_plugin_owned_skill_and_app_duplicates() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -5506,13 +5461,20 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mentions = composer.mention_items();
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
|
||||
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(mentions.len(), 3);
|
||||
assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string()));
|
||||
assert_eq!(
|
||||
mentions[0].path,
|
||||
Some("/tmp/repo/google-calendar/SKILL.md".to_string())
|
||||
);
|
||||
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
|
||||
assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(
|
||||
mentions[1].path,
|
||||
Some("plugin://google-calendar@debug".to_string())
|
||||
);
|
||||
assert_eq!(mentions[2].category_tag, Some("[App]".to_string()));
|
||||
assert_eq!(mentions[2].path, Some("app://google_calendar".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6169,6 +6169,7 @@ impl ChatWidget {
|
||||
| ServerNotification::McpServerStatusUpdated(_)
|
||||
| ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::ContextCompacted(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user