mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
160 Commits
sqlite-str
...
v2026.6.1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24885a9bf5 | ||
|
|
271ddaf115 | ||
|
|
04b9f875dc | ||
|
|
8466ebbca4 | ||
|
|
4affc5e6d3 | ||
|
|
cea0bc4c27 | ||
|
|
00c53e7f10 | ||
|
|
f5ac354b0f | ||
|
|
0e8a7cc6cc | ||
|
|
b9a4f500d0 | ||
|
|
053f558e4f | ||
|
|
0716d87496 | ||
|
|
35d6f18446 | ||
|
|
e41df15a00 | ||
|
|
25843cf39a | ||
|
|
88d3dd058d | ||
|
|
9e58ef1c82 | ||
|
|
eaeccf5fdf | ||
|
|
2c0e835b48 | ||
|
|
b942a958b3 | ||
|
|
42bcf9cd0b | ||
|
|
a0fbb6cfe2 | ||
|
|
408fa6e951 | ||
|
|
671909d6d3 | ||
|
|
409f78a1ea | ||
|
|
3e592a8bd7 | ||
|
|
e895479a21 | ||
|
|
930bc9691b | ||
|
|
b9f181635f | ||
|
|
c2aaf8afec | ||
|
|
cbc5f277bb | ||
|
|
44b388f863 | ||
|
|
c0e49a2c52 | ||
|
|
c1e132195d | ||
|
|
5bd8dbd0b8 | ||
|
|
421ea1f458 | ||
|
|
1f91e97353 | ||
|
|
d4f6e0a1f2 | ||
|
|
ec2455a842 | ||
|
|
1742f3f77c | ||
|
|
5117f457bb | ||
|
|
8fe5e83462 | ||
|
|
27097bed65 | ||
|
|
1849a86dd2 | ||
|
|
5280d1d95d | ||
|
|
bcdc93d651 | ||
|
|
0751b6f2c9 | ||
|
|
7d9fae5b3a | ||
|
|
a595aba60e | ||
|
|
75645aec08 | ||
|
|
d10d71cdb6 | ||
|
|
c69a8d633d | ||
|
|
d8ebbedf45 | ||
|
|
9ed1766696 | ||
|
|
bed0fb7bad | ||
|
|
db6fc20559 | ||
|
|
1364acbe4c | ||
|
|
d2988e0248 | ||
|
|
8c8c8c8e32 | ||
|
|
8bee3be90a | ||
|
|
87d890003d | ||
|
|
aed7de306e | ||
|
|
859cb52b44 | ||
|
|
4685a84e9b | ||
|
|
f30235bed2 | ||
|
|
4f8f6c7693 | ||
|
|
055063f06b | ||
|
|
dac33c8ecb | ||
|
|
75ebf1c870 | ||
|
|
e4a32b9e8e | ||
|
|
22e3b2e94e | ||
|
|
729420c34a | ||
|
|
0b5be66ef7 | ||
|
|
8e28c773fe | ||
|
|
2dcb681f38 | ||
|
|
e733774e3c | ||
|
|
004835f4c7 | ||
|
|
97d373ff37 | ||
|
|
3119f08009 | ||
|
|
9d55fc4579 | ||
|
|
2bac970abc | ||
|
|
f8e9ba3718 | ||
|
|
26aaf03719 | ||
|
|
e85be626a4 | ||
|
|
9cb052ccef | ||
|
|
637b073119 | ||
|
|
174e7711f3 | ||
|
|
b13af38f99 | ||
|
|
4094c94a8f | ||
|
|
32113e38ab | ||
|
|
07a425aa14 | ||
|
|
db5bb1cbe7 | ||
|
|
947dde976c | ||
|
|
1d4c1ba56d | ||
|
|
de3ee3daa6 | ||
|
|
61574eb50b | ||
|
|
e680604577 | ||
|
|
2ea7c518a5 | ||
|
|
7f95733bee | ||
|
|
a4196a4445 | ||
|
|
688634ccb9 | ||
|
|
060d4a4d2d | ||
|
|
f2d0fe6417 | ||
|
|
6627b4fbdd | ||
|
|
3b64ea83e8 | ||
|
|
1d62f4c014 | ||
|
|
3feeb95668 | ||
|
|
402e2bb81a | ||
|
|
bc470713bb | ||
|
|
3322212f14 | ||
|
|
7591dc6f4b | ||
|
|
6640d57b64 | ||
|
|
ac734d8e16 | ||
|
|
0ece07cc20 | ||
|
|
5e09113ede | ||
|
|
bff66a3e49 | ||
|
|
8071b06634 | ||
|
|
61ffd6bc66 | ||
|
|
474ec157bc | ||
|
|
1377fd82a9 | ||
|
|
8fdb1d0f55 | ||
|
|
68bfacae03 | ||
|
|
371617f9ed | ||
|
|
69b2c8bd15 | ||
|
|
c11ff35841 | ||
|
|
ddbd595f2f | ||
|
|
01124cfca9 | ||
|
|
e8f3bce9f0 | ||
|
|
cb0ad281ce | ||
|
|
c429a3c472 | ||
|
|
444bdc4286 | ||
|
|
28550c3847 | ||
|
|
3e91c688ae | ||
|
|
4d49a76039 | ||
|
|
988ec0234e | ||
|
|
9a7e0d43da | ||
|
|
f55ff8dd1b | ||
|
|
5314a39ee5 | ||
|
|
44cad6f8a4 | ||
|
|
275caeb5f5 | ||
|
|
0f2732b066 | ||
|
|
59f1472bd5 | ||
|
|
630f0d6938 | ||
|
|
6173a4babb | ||
|
|
6a1b2e6463 | ||
|
|
fb9e091852 | ||
|
|
00399d6c75 | ||
|
|
b23ace1d04 | ||
|
|
db4990d260 | ||
|
|
4550cfa6a7 | ||
|
|
c0195f7ed5 | ||
|
|
785849d395 | ||
|
|
12d5043913 | ||
|
|
d925249ac0 | ||
|
|
74a075077c | ||
|
|
4e57546a87 | ||
|
|
711ab45025 | ||
|
|
e7e21caa20 | ||
|
|
945faf8e67 | ||
|
|
1aa1a70ac5 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -1086,7 +1086,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1193,7 +1193,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
19
.github/workflows/crabbox-hydrate.yml
vendored
19
.github/workflows/crabbox-hydrate.yml
vendored
@@ -431,6 +431,25 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
$workspaceNodeModules = Join-Path $workspace "node_modules"
|
||||
if (Test-Path $workspaceNodeModules) {
|
||||
$workspaceNodeModulesItem = Get-Item $workspaceNodeModules -Force
|
||||
if (($workspaceNodeModulesItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) {
|
||||
$nodeModulesChildren = @(Get-ChildItem -LiteralPath $workspaceNodeModules -Force)
|
||||
$hasOnlyPnpmWorkspaceState = $nodeModulesChildren.Count -eq 1 -and $nodeModulesChildren[0].Name -eq ".pnpm-workspace-state-v1.json"
|
||||
if ($nodeModulesChildren.Count -ne 0 -and -not $hasOnlyPnpmWorkspaceState) {
|
||||
throw "workspace node_modules exists and is not a link: $workspaceNodeModules"
|
||||
}
|
||||
foreach ($nodeModulesChild in $nodeModulesChildren) {
|
||||
Remove-Item -LiteralPath $nodeModulesChild.FullName -Force
|
||||
}
|
||||
Remove-Item -LiteralPath $workspaceNodeModules -Force
|
||||
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
|
||||
}
|
||||
} else {
|
||||
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
|
||||
}
|
||||
|
||||
$corepackShimDir = Join-Path $nodeBin "node_modules\corepack\shims"
|
||||
if (Test-Path $corepackShimDir) {
|
||||
$env:PNPM_HOME = $corepackShimDir
|
||||
|
||||
@@ -902,7 +902,7 @@ jobs:
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
continue-on-error: true
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -933,7 +933,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run runtime parity lane
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,7 +2,13 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.5.31
|
||||
## 2026.6.1-alpha.3
|
||||
|
||||
### Fixes
|
||||
|
||||
- Alpha release candidate with refreshed release metadata, shrinkwrap alignment, and release-gate stabilization fixes.
|
||||
|
||||
## 2026.6.1
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -13,10 +19,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
|
||||
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
|
||||
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, and expose calmer composer controls. (#88772, #88825)
|
||||
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, trace first-output latency, and expose calmer composer controls. (#88772, #88825, #88998) Thanks @vincentkoc.
|
||||
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
|
||||
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
|
||||
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
|
||||
- Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, and rollback snapshots so failures report bounded proof instead of stalling.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -35,7 +41,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
|
||||
- Code mode: add MCP API files and docs for code-mode integrations.
|
||||
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
|
||||
- Control UI: add calmer chat composer controls for active chat entry. (#88772)
|
||||
- Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.
|
||||
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
|
||||
- Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)
|
||||
- Providers: add MiniMax M3 model support. (#88860)
|
||||
@@ -49,6 +55,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
|
||||
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
|
||||
- Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex `lastGood` auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.
|
||||
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
|
||||
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
|
||||
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
|
||||
@@ -58,9 +65,10 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
|
||||
- Plugins: preserve npm plugin roots after blocked installs, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
|
||||
- Plugins: preserve npm plugin roots after blocked installs, skip plugin-local `openclaw` peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
|
||||
- Memory: serialize QMD update/embed writes per store, preserve phase signals on read errors, harden envelope metadata sanitization, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931) Thanks @openperf and @amittell.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
@@ -70,14 +78,15 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.
|
||||
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
|
||||
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, honor Chromium executable overrides, and detect system Chromium for E2E.
|
||||
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
|
||||
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.
|
||||
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
|
||||
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
|
||||
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
|
||||
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)
|
||||
|
||||
@@ -66,7 +66,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026053101
|
||||
versionName = "2026.5.31"
|
||||
versionName = "2026.6.1"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
/** App entry shown in the notification-forwarding package picker. */
|
||||
data class InstalledApp(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val isSystemApp: Boolean,
|
||||
)
|
||||
|
||||
/** Reads launcher, recent-notification, and configured packages for the picker. */
|
||||
internal fun queryInstalledApps(
|
||||
context: Context,
|
||||
configuredPackages: Set<String>,
|
||||
): List<InstalledApp> {
|
||||
val packageManager = context.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
|
||||
val launcherPackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
it.activityInfo
|
||||
?.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}.toMutableSet()
|
||||
|
||||
val recentNotificationPackages =
|
||||
DeviceNotificationListenerService
|
||||
.recentPackages(context)
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
val candidatePackages =
|
||||
resolveNotificationCandidatePackages(
|
||||
launcherPackages = launcherPackages,
|
||||
recentPackages = recentNotificationPackages,
|
||||
configuredPackages = configuredPackages,
|
||||
appPackageName = context.packageName,
|
||||
)
|
||||
|
||||
return candidatePackages
|
||||
.asSequence()
|
||||
.mapNotNull { packageName ->
|
||||
runCatching {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
|
||||
InstalledApp(
|
||||
label = if (label.isEmpty()) packageName else label,
|
||||
packageName = packageName,
|
||||
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
|
||||
)
|
||||
}.getOrNull()
|
||||
}.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
|
||||
internal fun resolveNotificationCandidatePackages(
|
||||
launcherPackages: Set<String>,
|
||||
recentPackages: List<String>,
|
||||
configuredPackages: Set<String>,
|
||||
appPackageName: String,
|
||||
): Set<String> {
|
||||
val blockedPackage = appPackageName.trim()
|
||||
return sequenceOf(
|
||||
configuredPackages.asSequence(),
|
||||
launcherPackages.asSequence(),
|
||||
recentPackages.asSequence(),
|
||||
).flatten()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != blockedPackage }
|
||||
.toSet()
|
||||
}
|
||||
@@ -493,6 +493,8 @@ private fun playVoiceSetupTone() {
|
||||
Handler(Looper.getMainLooper()).postDelayed({ tone.release() }, 300L)
|
||||
}
|
||||
|
||||
private const val NOTIFICATION_PICKER_RESULT_LIMIT = 40
|
||||
|
||||
@Composable
|
||||
private fun NotificationSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -507,6 +509,19 @@ private fun NotificationSettingsScreen(
|
||||
val quietEnd by viewModel.notificationForwardingQuietEnd.collectAsState()
|
||||
val maxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState()
|
||||
val modeLabel = if (mode == NotificationPackageFilterMode.Blocklist) "Blocklist" else "Allowlist"
|
||||
val installedApps = remember(context, packages) { queryInstalledApps(context, packages) }
|
||||
var notificationPickerExpanded by remember { mutableStateOf(false) }
|
||||
var notificationAppSearch by remember { mutableStateOf("") }
|
||||
var notificationShowSystemApps by remember { mutableStateOf(false) }
|
||||
val filteredApps =
|
||||
remember(installedApps, packages, notificationAppSearch, notificationShowSystemApps) {
|
||||
filterNotificationAppsForPicker(
|
||||
apps = installedApps,
|
||||
selectedPackages = packages,
|
||||
query = notificationAppSearch,
|
||||
showSystemApps = notificationShowSystemApps,
|
||||
)
|
||||
}
|
||||
var listenerEnabled by remember { mutableStateOf(DeviceNotificationListenerService.isAccessEnabled(context)) }
|
||||
val notificationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
@@ -567,6 +582,124 @@ private fun NotificationSettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
NotificationPackagePickerPanel(
|
||||
mode = mode,
|
||||
selectedPackages = packages,
|
||||
apps = filteredApps,
|
||||
search = notificationAppSearch,
|
||||
showSystemApps = notificationShowSystemApps,
|
||||
expanded = notificationPickerExpanded,
|
||||
onSearchChange = { notificationAppSearch = it },
|
||||
onShowSystemAppsChange = { notificationShowSystemApps = it },
|
||||
onExpandedChange = { notificationPickerExpanded = it },
|
||||
onPackageSelectionChange = { packageName, selected ->
|
||||
val next = packages.toMutableSet()
|
||||
if (selected) {
|
||||
next.add(packageName)
|
||||
} else {
|
||||
next.remove(packageName)
|
||||
}
|
||||
viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(","))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationPackagePickerPanel(
|
||||
mode: NotificationPackageFilterMode,
|
||||
selectedPackages: Set<String>,
|
||||
apps: List<InstalledApp>,
|
||||
search: String,
|
||||
showSystemApps: Boolean,
|
||||
expanded: Boolean,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onShowSystemAppsChange: (Boolean) -> Unit,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onPackageSelectionChange: (String, Boolean) -> Unit,
|
||||
) {
|
||||
val visibleApps = apps.take(NOTIFICATION_PICKER_RESULT_LIMIT)
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "App Filter", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = notificationPackageSelectionSummary(mode = mode, selectedCount = selectedPackages.size),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
ClawSecondaryButton(
|
||||
text = if (expanded) "Close App Picker" else "Open App Picker",
|
||||
onClick = { onExpandedChange(!expanded) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (expanded) {
|
||||
ClawTextField(value = search, onValueChange = onSearchChange, placeholder = "Search apps")
|
||||
SettingsToggleListRow(
|
||||
SettingsToggleRow(
|
||||
title = "Show System Apps",
|
||||
subtitle = "Include Android and background packages.",
|
||||
icon = Icons.Default.Storage,
|
||||
checked = showSystemApps,
|
||||
onCheckedChange = onShowSystemAppsChange,
|
||||
),
|
||||
)
|
||||
if (visibleApps.isEmpty()) {
|
||||
Text(text = "No matching apps.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
} else {
|
||||
ClawSeparatedColumn(items = visibleApps) { app ->
|
||||
NotificationPackageAppRow(
|
||||
app = app,
|
||||
selected = selectedPackages.contains(app.packageName),
|
||||
onSelectedChange = { selected -> onPackageSelectionChange(app.packageName, selected) },
|
||||
)
|
||||
}
|
||||
if (apps.size > visibleApps.size) {
|
||||
Text(
|
||||
text = "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.",
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationPackageAppRow(
|
||||
app: InstalledApp,
|
||||
selected: Boolean,
|
||||
onSelectedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 58.dp)
|
||||
.clickable { onSelectedChange(!selected) }
|
||||
.padding(vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
ClawTextBadge(text = notificationAppBadge(app.label))
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
text = app.label,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Switch(checked = selected, onCheckedChange = onSelectedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1112,6 +1245,55 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun filterNotificationAppsForPicker(
|
||||
apps: List<InstalledApp>,
|
||||
selectedPackages: Set<String>,
|
||||
query: String,
|
||||
showSystemApps: Boolean,
|
||||
): List<InstalledApp> {
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
return apps.filter { app ->
|
||||
val selected = app.packageName in selectedPackages
|
||||
val visibleByType = showSystemApps || !app.isSystemApp || selected
|
||||
val visibleBySearch =
|
||||
normalizedQuery.isEmpty() ||
|
||||
app.label.lowercase().contains(normalizedQuery) ||
|
||||
app.packageName.lowercase().contains(normalizedQuery)
|
||||
visibleByType && visibleBySearch
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationPackageSelectionSummary(
|
||||
mode: NotificationPackageFilterMode,
|
||||
selectedCount: Int,
|
||||
): String =
|
||||
when (mode) {
|
||||
NotificationPackageFilterMode.Allowlist ->
|
||||
if (selectedCount == 0) {
|
||||
"No apps selected. Nothing forwards until you add apps."
|
||||
} else {
|
||||
"$selectedCount ${if (selectedCount == 1) "app" else "apps"} allowed to forward."
|
||||
}
|
||||
NotificationPackageFilterMode.Blocklist ->
|
||||
if (selectedCount == 0) {
|
||||
"No apps blocked. Apps can forward unless you add blocks."
|
||||
} else {
|
||||
"$selectedCount ${if (selectedCount == 1) "app" else "apps"} blocked from forwarding."
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationAppBadge(label: String): String {
|
||||
val initials =
|
||||
label
|
||||
.split(' ', '-', '_', '.')
|
||||
.asSequence()
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
return initials.ifBlank { "A" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts cron wake times into short relative labels for scheduled-work rows.
|
||||
*/
|
||||
|
||||
@@ -1222,82 +1222,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/** App entry shown in the notification-forwarding package picker. */
|
||||
data class InstalledApp(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val isSystemApp: Boolean,
|
||||
)
|
||||
|
||||
/** Reads launcher, recent-notification, and configured packages for the picker. */
|
||||
private fun queryInstalledApps(
|
||||
context: Context,
|
||||
configuredPackages: Set<String>,
|
||||
): List<InstalledApp> {
|
||||
val packageManager = context.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
|
||||
val launcherPackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
it.activityInfo
|
||||
?.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}.toMutableSet()
|
||||
|
||||
val recentNotificationPackages =
|
||||
DeviceNotificationListenerService
|
||||
.recentPackages(context)
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
val candidatePackages =
|
||||
resolveNotificationCandidatePackages(
|
||||
launcherPackages = launcherPackages,
|
||||
recentPackages = recentNotificationPackages,
|
||||
configuredPackages = configuredPackages,
|
||||
appPackageName = context.packageName,
|
||||
)
|
||||
|
||||
return candidatePackages
|
||||
.asSequence()
|
||||
.mapNotNull { packageName ->
|
||||
runCatching {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
|
||||
InstalledApp(
|
||||
label = if (label.isEmpty()) packageName else label,
|
||||
packageName = packageName,
|
||||
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
|
||||
)
|
||||
}.getOrNull()
|
||||
}.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
|
||||
internal fun resolveNotificationCandidatePackages(
|
||||
launcherPackages: Set<String>,
|
||||
recentPackages: List<String>,
|
||||
configuredPackages: Set<String>,
|
||||
appPackageName: String,
|
||||
): Set<String> {
|
||||
val blockedPackage = appPackageName.trim()
|
||||
return sequenceOf(
|
||||
configuredPackages.asSequence(),
|
||||
launcherPackages.asSequence(),
|
||||
recentPackages.asSequence(),
|
||||
).flatten()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != blockedPackage }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/** Shared Material text-field colors for the legacy mobile settings sheet. */
|
||||
@Composable
|
||||
private fun settingsTextFieldColors() =
|
||||
|
||||
@@ -32,4 +32,46 @@ class SettingsSheetNotificationAppsTest {
|
||||
|
||||
assertEquals(setOf("com.example.recent", "com.example.configured"), packages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterNotificationAppsForPicker_keepsSelectedSystemPackagesVisible() {
|
||||
val apps =
|
||||
listOf(
|
||||
InstalledApp(label = "Android System", packageName = "android", isSystemApp = true),
|
||||
InstalledApp(label = "Phone Services", packageName = "com.android.phone", isSystemApp = true),
|
||||
InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false),
|
||||
)
|
||||
|
||||
val filtered =
|
||||
filterNotificationAppsForPicker(
|
||||
apps = apps,
|
||||
selectedPackages = setOf("com.android.phone"),
|
||||
query = "",
|
||||
showSystemApps = false,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("com.android.phone", "com.google.android.gm"),
|
||||
filtered.map { it.packageName },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterNotificationAppsForPicker_matchesLabelsAndPackageNames() {
|
||||
val apps =
|
||||
listOf(
|
||||
InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false),
|
||||
InstalledApp(label = "Calendar", packageName = "com.google.android.calendar", isSystemApp = false),
|
||||
)
|
||||
|
||||
val filtered =
|
||||
filterNotificationAppsForPicker(
|
||||
apps = apps,
|
||||
selectedPackages = emptySet(),
|
||||
query = "gm",
|
||||
showSystemApps = false,
|
||||
)
|
||||
|
||||
assertEquals(listOf("com.google.android.gm"), filtered.map { it.packageName })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.31 - 2026-05-31
|
||||
## 2026.6.1 - 2026-06-01
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.31
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.31
|
||||
OPENCLAW_IOS_VERSION = 2026.6.1
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.1
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.5.31"
|
||||
"version": "2026.6.1"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.5.31</string>
|
||||
<string>2026.6.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026053100</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
cc0fb4e3f1a7e8f233626adb80d686608ddac8c177fe6a55b33970c2baf4ace4 config-baseline.json
|
||||
67914673462dfdc43d383568208d8c562fc49a66d2a1c1953b8e76e956001cc7 config-baseline.json
|
||||
042ca98e6200a365accda00e5a6f3e72bdae5853f39ff0cdc3b2cb9c0d6f8f3e config-baseline.core.json
|
||||
cbf81829dcc8cfd0a16435912da709f8c1d508707385b6493f94cafe211ec67c config-baseline.channel.json
|
||||
3c67681c98170fa88c78db31fc431ed34b2161219d8ee4d4f5152e4599af3971 config-baseline.channel.json
|
||||
4012b1f8de6f9527c47320a6c7120f30dc30ac1b5524ed63dadef890aad44b20 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
47d4365c4133f57769758907b7cf1a43d17e040db0570a1433e2f03e4fb0bd02 plugin-sdk-api-baseline.json
|
||||
161027bba89497f5e30127dd0f57b4da623270cfc771b989e29262da5760e723 plugin-sdk-api-baseline.jsonl
|
||||
63d49032a9b4dc4874a0ca17be73ecc97a2df5d1f47b4e72db34868423370558 plugin-sdk-api-baseline.json
|
||||
af79f7d711afa0a8563782b8f5cdd7e46b9aea245f5e7ebc464327a8969ed65e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -329,6 +329,19 @@ openclaw plugins install -l ./my-plugin
|
||||
Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped.
|
||||
|
||||
<Note>
|
||||
Workspace-origin plugins discovered from a workspace extensions root are not
|
||||
imported or executed until they are explicitly enabled. For local development,
|
||||
run `openclaw plugins enable <plugin-id>` or set
|
||||
`plugins.entries.<plugin-id>.enabled: true`; if your config uses
|
||||
`plugins.allow`, include the same plugin id there too. This fail-closed rule
|
||||
also applies when channel setup explicitly targets a workspace-origin plugin for
|
||||
setup-only loading, so local channel plugin setup code will not run while that
|
||||
workspace plugin remains disabled or excluded from the allowlist. Linked installs
|
||||
and explicit `plugins.load.paths` entries follow the normal policy for their
|
||||
resolved plugin origin. See
|
||||
[Configure plugin policy](/tools/plugin#configure-plugin-policy)
|
||||
and [Configuration reference](/gateway/configuration-reference#plugins).
|
||||
|
||||
`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target.
|
||||
|
||||
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in the managed plugin index while keeping the default behavior unpinned.
|
||||
|
||||
@@ -368,7 +368,7 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
|
||||
- GPT-5.4 mini
|
||||
- GPT-5.2
|
||||
|
||||
The current bundled harness is `@openai/codex` `0.134.0`. A `model/list` probe
|
||||
The current bundled harness is `@openai/codex` `0.135.0`. A `model/list` probe
|
||||
against that bundled app-server returned:
|
||||
|
||||
| Model id | Default | Hidden | Input modalities | Reasoning efforts |
|
||||
|
||||
@@ -190,11 +190,10 @@ plugins, channels, and core code only see the standard
|
||||
|
||||
When `harness.compact` runs, the Copilot SDK harness:
|
||||
|
||||
1. Enables `infiniteSessions` on the SDK session.
|
||||
2. Lets the SDK perform its native compaction.
|
||||
3. Writes an OpenClaw-shaped marker at
|
||||
`workspacePath/files/openclaw-compaction-<ts>.json` so existing OpenClaw
|
||||
transcript readers still see a familiar artifact.
|
||||
1. Resumes the tracked SDK session without continuing pending work.
|
||||
2. Calls the SDK's session-scoped history compaction RPC.
|
||||
3. Returns the SDK compaction outcome without writing compatibility marker
|
||||
files under the workspace.
|
||||
|
||||
The OpenClaw side transcript mirror (see below) continues to receive the
|
||||
post-compaction messages, so user-facing chat history stays consistent.
|
||||
|
||||
@@ -107,7 +107,7 @@ commands.
|
||||
| [oc-path](/plugins/reference/oc-path) | Adds the openclaw path CLI for oc:// workspace file addressing. | `@openclaw/oc-path`<br />included in OpenClaw | plugin |
|
||||
| [ollama](/plugins/reference/ollama) | Adds Ollama, Ollama Cloud model provider support to OpenClaw. | `@openclaw/ollama-provider`<br />included in OpenClaw | providers: ollama, ollama-cloud; contracts: memoryEmbeddingProviders, webSearchProviders |
|
||||
| [open-prose](/plugins/reference/open-prose) | OpenProse VM skill pack with a /prose slash command. | `@openclaw/open-prose`<br />included in OpenClaw | skills |
|
||||
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
|
||||
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
|
||||
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
|
||||
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
|
||||
|
||||
@@ -95,7 +95,7 @@ pnpm plugins:inventory:gen
|
||||
| [oc-path](/plugins/reference/oc-path) | Adds the openclaw path CLI for oc:// workspace file addressing. | `@openclaw/oc-path`<br />included in OpenClaw | plugin |
|
||||
| [ollama](/plugins/reference/ollama) | Adds Ollama, Ollama Cloud model provider support to OpenClaw. | `@openclaw/ollama-provider`<br />included in OpenClaw | providers: ollama, ollama-cloud; contracts: memoryEmbeddingProviders, webSearchProviders |
|
||||
| [open-prose](/plugins/reference/open-prose) | OpenProse VM skill pack with a /prose slash command. | `@openclaw/open-prose`<br />included in OpenClaw | skills |
|
||||
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
|
||||
| [openai](/plugins/reference/openai) | Adds OpenAI model provider support to OpenClaw. | `@openclaw/openai-provider`<br />included in OpenClaw | providers: openai; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, speechProviders, videoGenerationProviders |
|
||||
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
|
||||
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth."
|
||||
summary: "Adds OpenAI model provider support to OpenClaw."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the openai plugin
|
||||
title: "OpenAI plugin"
|
||||
@@ -7,7 +7,7 @@ title: "OpenAI plugin"
|
||||
|
||||
# OpenAI plugin
|
||||
|
||||
Adds OpenAI model provider support to OpenClaw, including ChatGPT/Codex OAuth.
|
||||
Adds OpenAI model provider support to OpenClaw.
|
||||
|
||||
## Distribution
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ by package contract guardrails.
|
||||
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
|
||||
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives plus Zod and direct JSON/TypeBox builders |
|
||||
| `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only |
|
||||
| `plugin-sdk/chat-channel-ids` | `BUNDLED_CHAT_CHANNEL_IDS`, `BUNDLED_CHAT_CHANNEL_ENVELOPE_PREFIXES`, `ChatChannelId`. Canonical bundled/official chat channel ids plus formatter labels/aliases for plugins that need to recognize envelope-prefixed text without hardcoding their own table. |
|
||||
| `plugin-sdk/channel-config-schema-legacy` | Deprecated compatibility alias for bundled-channel config schemas |
|
||||
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
|
||||
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
|
||||
|
||||
@@ -457,6 +457,10 @@ The branch already has a real shared SQLite base:
|
||||
- GitHub Copilot token exchange cache uses the shared SQLite plugin-state table
|
||||
under `github-copilot/token-cache/default`. It is provider-owned cache state,
|
||||
so it intentionally does not add a host schema table.
|
||||
- GitHub Copilot compaction no longer writes `openclaw-compaction-*.json`
|
||||
workspace sidecars. The harness calls the SDK history compaction RPC for the
|
||||
tracked SDK session, and OpenClaw keeps durable session/transcript state in
|
||||
SQLite instead of compatibility marker files.
|
||||
- The shared Swift runtime (`OpenClawKit`) uses the same
|
||||
`state/openclaw.sqlite` rows for device identity and device auth. macOS app
|
||||
helpers import the shared SQLite helpers instead of owning a second JSON or
|
||||
|
||||
4
extensions/acpx/npm-shrinkwrap.json
generated
4
extensions/acpx/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,10 +26,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31",
|
||||
"openclawVersion": "2026.6.1-alpha.3",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.100.1",
|
||||
"@aws/bedrock-token-generator": "1.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -24,10 +24,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31",
|
||||
"openclawVersion": "2026.6.1-alpha.3",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1056.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1056.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -28,10 +28,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31",
|
||||
"openclawVersion": "2026.6.1-alpha.3",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -23,10 +23,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31",
|
||||
"openclawVersion": "2026.6.1-alpha.3",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Arcee provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
4
extensions/brave/npm-shrinkwrap.json
generated
4
extensions/brave/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.5.31"
|
||||
"version": "2026.6.1-alpha.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31"
|
||||
"openclawVersion": "2026.6.1-alpha.3"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Canvas plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cerebras provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw ClickClack channel plugin",
|
||||
"type": "module",
|
||||
@@ -18,7 +18,7 @@
|
||||
"openclaw": "2026.5.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.31"
|
||||
"openclaw": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex-supervisor",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Codex app-server fleet supervision plugin.",
|
||||
"type": "module",
|
||||
|
||||
@@ -253,6 +253,8 @@ describe("codex media understanding provider", () => {
|
||||
expect(result?.text).toBe("A red square.");
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
4
extensions/codex/npm-shrinkwrap.json
generated
4
extensions/codex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.135.0",
|
||||
"typebox": "1.1.39",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,10 +26,10 @@
|
||||
"minHostVersion": ">=2026.5.1-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31"
|
||||
"openclawVersion": "2026.6.1-alpha.3"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -121,12 +121,14 @@ async function waitForThreadStart(harness: ClientHarness): Promise<{ id?: number
|
||||
|
||||
describe("startCodexAttemptThread", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
clearSharedCodexAppServerClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
clearSharedCodexAppServerClient();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCodexSteeringQueue } from "./attempt-steering.js";
|
||||
|
||||
describe("Codex app-server steering queue", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -16,7 +20,9 @@ describe("Codex app-server steering queue", () => {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await queue.queue("accepted", { debounceMs: 0 });
|
||||
const queued = queue.queue("accepted", { debounceMs: 0 });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await queued;
|
||||
|
||||
expect(request).toHaveBeenCalledWith("turn/steer", {
|
||||
threadId: "thread-1",
|
||||
@@ -37,9 +43,10 @@ describe("Codex app-server steering queue", () => {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await expect(queue.queue("rejected", { debounceMs: 0 })).rejects.toThrow(
|
||||
"cannot steer a compact turn",
|
||||
);
|
||||
const queued = queue.queue("rejected", { debounceMs: 0 });
|
||||
const rejected = expect(queued).rejects.toThrow("cannot steer a compact turn");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await rejected;
|
||||
expect(request).toHaveBeenCalledWith("turn/steer", {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
@@ -48,7 +55,6 @@ describe("Codex app-server steering queue", () => {
|
||||
});
|
||||
|
||||
it("rejects queued steering when the run aborts before debounce flush", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
const request = vi.fn(async () => ({ turnId: "turn-1" }));
|
||||
const queue = createCodexSteeringQueue({
|
||||
|
||||
@@ -82,6 +82,10 @@ export function createCodexSteeringQueue(params: {
|
||||
batchedTexts.push({ text, resolve, reject });
|
||||
clearBatchTimer();
|
||||
const debounceMs = normalizeCodexSteerDebounceMs(options?.debounceMs);
|
||||
if (debounceMs === 0) {
|
||||
void flushBatch().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
batchTimer = setTimeout(() => {
|
||||
batchTimer = undefined;
|
||||
void flushBatch().catch(() => undefined);
|
||||
|
||||
@@ -9,6 +9,8 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -91,6 +93,28 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
|
||||
});
|
||||
|
||||
it("prefers completion idle timeout when completion and progress watches are due together", () => {
|
||||
const harness = createController();
|
||||
|
||||
harness.controller.armAttemptIdleWatch();
|
||||
harness.controller.touchActivity("request:item/tool/call:response", {
|
||||
arm: true,
|
||||
attemptProgress: true,
|
||||
attemptTimeoutMs: 10,
|
||||
});
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(harness.timeouts).toMatchObject([
|
||||
{
|
||||
kind: "completion",
|
||||
idleMs: 10,
|
||||
timeoutMs: 10,
|
||||
lastActivityReason: "request:item/tool/call:response",
|
||||
},
|
||||
]);
|
||||
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
|
||||
});
|
||||
|
||||
it("clamps oversized completion idle timeouts before scheduling", () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const harness = createController({
|
||||
|
||||
@@ -166,6 +166,23 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
scheduleTerminalIdleWatch();
|
||||
}
|
||||
|
||||
function isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs: number) {
|
||||
if (
|
||||
params.isCompleted() ||
|
||||
params.isTerminalTurnNotificationQueued() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const completionTimeoutMs = completionIdleTimeoutOverrideMs ?? turnCompletionIdleTimeoutMs;
|
||||
if (completionTimeoutMs > timeoutMs) {
|
||||
return false;
|
||||
}
|
||||
return Math.max(0, Date.now() - completionLastActivityAt) >= completionTimeoutMs;
|
||||
}
|
||||
|
||||
function recordAttemptProgress(
|
||||
reason: string,
|
||||
options?: { details?: Record<string, unknown>; attemptTimeoutMs?: number },
|
||||
@@ -236,6 +253,10 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
scheduleAttemptIdleWatch();
|
||||
return;
|
||||
}
|
||||
if (isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs)) {
|
||||
fireCompletionIdleTimeout();
|
||||
return;
|
||||
}
|
||||
const timeout = {
|
||||
kind: "progress" as const,
|
||||
idleMs,
|
||||
|
||||
@@ -27,6 +27,11 @@ import type {
|
||||
import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js";
|
||||
|
||||
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai";
|
||||
const LEGACY_CODEX_APP_SERVER_AUTH_PROVIDER = "codex-cli";
|
||||
const CODEX_APP_SERVER_EXTERNAL_CLI_PROVIDER_IDS = [
|
||||
CODEX_APP_SERVER_AUTH_PROVIDER,
|
||||
LEGACY_CODEX_APP_SERVER_AUTH_PROVIDER,
|
||||
];
|
||||
const OPENAI_PROVIDER = "openai";
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai:default";
|
||||
const CODEX_HOME_ENV_VAR = "CODEX_HOME";
|
||||
@@ -120,7 +125,7 @@ function ensureCodexAppServerAuthProfileStore(params: {
|
||||
return ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
config: params.config,
|
||||
externalCliProviderIds: [CODEX_APP_SERVER_AUTH_PROVIDER],
|
||||
externalCliProviderIds: CODEX_APP_SERVER_EXTERNAL_CLI_PROVIDER_IDS,
|
||||
...(params.authProfileId ? { externalCliProfileIds: [params.authProfileId] } : {}),
|
||||
});
|
||||
}
|
||||
@@ -599,7 +604,13 @@ async function resolveOAuthCredentialForCodexAppServer(
|
||||
}
|
||||
|
||||
function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean {
|
||||
return resolveProviderIdForAuth(provider, { config }) === CODEX_APP_SERVER_AUTH_PROVIDER;
|
||||
const resolvedProvider = resolveProviderIdForAuth(provider, { config });
|
||||
return (
|
||||
resolvedProvider === CODEX_APP_SERVER_AUTH_PROVIDER ||
|
||||
// Older Codex auth profiles stored the CLI runtime id here. The app-server
|
||||
// login protocol still receives the same externally managed ChatGPT token.
|
||||
resolvedProvider === LEGACY_CODEX_APP_SERVER_AUTH_PROVIDER
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenAIApiKeyBackupCredential(
|
||||
|
||||
@@ -157,10 +157,12 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId);
|
||||
resetCodexAppServerClientFactoryForTest();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -29,8 +29,8 @@ describe("CodexAppServerClient", () => {
|
||||
|
||||
afterEach(() => {
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { CodexDynamicToolCallResponse } from "./protocol.js";
|
||||
|
||||
describe("dynamic tool execution helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps explicit dynamic tool timeouts above the default bridge deadline", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
abortAndDrainAgentHarnessRun,
|
||||
nativeHookRelayTesting,
|
||||
queueAgentHarnessMessage,
|
||||
resetAgentEventsForTest,
|
||||
@@ -30,6 +31,8 @@ const appServerHarnessWait = { interval: 1, timeout: 120_000 } as const;
|
||||
const activeAppServerAttemptsForTest = new Set<{
|
||||
abortController?: AbortController;
|
||||
promise: Promise<unknown>;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}>();
|
||||
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
@@ -62,6 +65,8 @@ export function runCodexAppServerAttempt(
|
||||
const entry = {
|
||||
abortController,
|
||||
promise: undefined as unknown as Promise<unknown>,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
};
|
||||
const promise = runCodexAppServerAttemptImpl(
|
||||
trackedParams,
|
||||
@@ -76,6 +81,7 @@ export function runCodexAppServerAttempt(
|
||||
}
|
||||
|
||||
async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
vi.useRealTimers();
|
||||
const attempts = [...activeAppServerAttemptsForTest];
|
||||
if (attempts.length === 0) {
|
||||
return;
|
||||
@@ -83,12 +89,33 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
for (const attempt of attempts) {
|
||||
attempt.abortController?.abort("test_cleanup");
|
||||
}
|
||||
await Promise.race([
|
||||
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 5_000);
|
||||
const drainedSessions = new Set<string>();
|
||||
const sessionDrains = attempts.flatMap((attempt) => {
|
||||
if (!attempt.sessionId || drainedSessions.has(attempt.sessionId)) {
|
||||
return [];
|
||||
}
|
||||
drainedSessions.add(attempt.sessionId);
|
||||
return [
|
||||
abortAndDrainAgentHarnessRun({
|
||||
sessionId: attempt.sessionId,
|
||||
sessionKey: attempt.sessionKey,
|
||||
settleMs: 1_000,
|
||||
forceClear: true,
|
||||
reason: "test_cleanup",
|
||||
}).catch(() => undefined),
|
||||
];
|
||||
});
|
||||
const drainResult = await Promise.race([
|
||||
Promise.allSettled([...attempts.map((attempt) => attempt.promise), ...sessionDrains]).then(
|
||||
() => "settled" as const,
|
||||
),
|
||||
new Promise<"timeout">((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), 5_000);
|
||||
}),
|
||||
]);
|
||||
if (drainResult === "settled") {
|
||||
activeAppServerAttemptsForTest.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
@@ -465,6 +492,7 @@ export function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTes
|
||||
|
||||
export function setupRunAttemptTestHooks(): void {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearInternalHooks();
|
||||
resetAgentEventsForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
@@ -489,8 +517,8 @@ export function setupRunAttemptTestHooks(): void {
|
||||
resetGlobalHookRunner();
|
||||
clearInternalHooks();
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
await closeCodexSandboxExecServersForTests();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -84,6 +84,7 @@ function turnStartResult(turnId = "turn-1") {
|
||||
|
||||
describe("Codex app-server main thread cleanup", () => {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
resetAgentEventsForTest();
|
||||
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
@@ -92,6 +93,7 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
resetAgentEventsForTest();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
@@ -804,6 +804,52 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("keeps mirrored history when an inactive per-turn context-engine binding starts fresh", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("previous per-turn request", 10) as never);
|
||||
sessionManager.appendMessage(assistantMessage("previous per-turn answer", 11) as never);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-per-turn-context",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
|
||||
},
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
throw new Error("inactive context-engine bindings should start a fresh thread");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
const inputText = getRequestInputText(harness);
|
||||
expect(inputText).toContain("OpenClaw assembled context for this turn:");
|
||||
expect(inputText).toContain("previous per-turn request");
|
||||
expect(inputText).toContain("previous per-turn answer");
|
||||
expect(inputText).toContain("Current user request:");
|
||||
expect(inputText).toContain("hello");
|
||||
|
||||
await harness.completeTurn("completed", "thread-fresh");
|
||||
await run;
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread and reprojects when context-engine epoch changes", async () => {
|
||||
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
|
||||
@@ -18,24 +18,44 @@ import {
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
function createSteeringParams(name: string) {
|
||||
const params = createParams(
|
||||
path.join(tempDir, `${name}.jsonl`),
|
||||
path.join(tempDir, `${name}-workspace`),
|
||||
);
|
||||
params.sessionId = `session-${name}`;
|
||||
params.sessionKey = `agent:main:session-${name}`;
|
||||
return params;
|
||||
}
|
||||
|
||||
async function queueActiveRunMessageEventually(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
options?: Parameters<typeof queueActiveRunMessageForTest>[2],
|
||||
) {
|
||||
await vi.waitFor(
|
||||
() => expect(queueActiveRunMessageForTest(sessionId, text, options)).toBe(true),
|
||||
fastWait,
|
||||
);
|
||||
}
|
||||
|
||||
describe("runCodexAppServerAttempt steering", () => {
|
||||
it("forwards queued user input and aborts the active app-server turn", async () => {
|
||||
const { requests, waitForMethod } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-forward");
|
||||
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
{ pluginConfig: { appServer: { mode: "yolo" } } },
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params, { pluginConfig: { appServer: { mode: "yolo" } } });
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
expect(queueActiveRunMessageForTest("session-1", "more context", { debounceMs: 1 })).toBe(true);
|
||||
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"), {
|
||||
interval: 1,
|
||||
});
|
||||
expect(abortAgentHarnessRun("session-1")).toBe(true);
|
||||
await queueActiveRunMessageEventually(params.sessionId, "more context", { debounceMs: 1 });
|
||||
await vi.waitFor(
|
||||
() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"),
|
||||
fastWait,
|
||||
);
|
||||
expect(abortAgentHarnessRun(params.sessionId)).toBe(true);
|
||||
await vi.waitFor(
|
||||
() => expect(requests.map((entry) => entry.method)).toContain("turn/interrupt"),
|
||||
{ interval: 1 },
|
||||
fastWait,
|
||||
);
|
||||
|
||||
const result = await run;
|
||||
@@ -67,22 +87,21 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
|
||||
it("accepts message-tool-only steering for active Codex app-server source replies", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
const params = createSteeringParams("steering-message-tool");
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
expect(
|
||||
queueActiveRunMessageForTest("session-1", "subagent complete", {
|
||||
await queueActiveRunMessageEventually(
|
||||
params.sessionId,
|
||||
"subagent complete",
|
||||
{
|
||||
debounceMs: 1,
|
||||
steeringMode: "all",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
@@ -96,7 +115,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ interval: 1 },
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -105,14 +124,13 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
|
||||
it("batches default queued steering before sending turn/steer", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-batch-default");
|
||||
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
expect(queueActiveRunMessageForTest("session-1", "first", { debounceMs: 5 })).toBe(true);
|
||||
expect(queueActiveRunMessageForTest("session-1", "second", { debounceMs: 5 })).toBe(true);
|
||||
await queueActiveRunMessageEventually(params.sessionId, "first", { debounceMs: 5 });
|
||||
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 5 })).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
@@ -129,7 +147,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ interval: 1 },
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -138,15 +156,12 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
|
||||
it("flushes pending default queued steering during normal turn cleanup", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-flush");
|
||||
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
expect(queueActiveRunMessageForTest("session-1", "late steer", { debounceMs: 30_000 })).toBe(
|
||||
true,
|
||||
);
|
||||
await queueActiveRunMessageEventually(params.sessionId, "late steer", { debounceMs: 30_000 });
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
@@ -165,17 +180,20 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
|
||||
it("batches explicit all-mode steering before sending turn/steer", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-batch-all");
|
||||
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await queueActiveRunMessageEventually(params.sessionId, "first", {
|
||||
debounceMs: 5,
|
||||
steeringMode: "all",
|
||||
});
|
||||
expect(
|
||||
queueActiveRunMessageForTest("session-1", "first", { debounceMs: 5, steeringMode: "all" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
queueActiveRunMessageForTest("session-1", "second", { debounceMs: 5, steeringMode: "all" }),
|
||||
queueActiveRunMessageForTest(params.sessionId, "second", {
|
||||
debounceMs: 5,
|
||||
steeringMode: "all",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
@@ -193,7 +211,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ interval: 1 },
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -235,10 +253,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
const params = createSteeringParams("steering-request-input");
|
||||
params.onBlockReply = vi.fn();
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(
|
||||
@@ -271,7 +286,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait);
|
||||
expect(queueActiveRunMessageForTest("session-1", "2")).toBe(true);
|
||||
await queueActiveRunMessageEventually(params.sessionId, "2");
|
||||
await expect(response).resolves.toEqual({
|
||||
answers: { mode: { answers: ["Deep"] } },
|
||||
});
|
||||
|
||||
@@ -3937,6 +3937,46 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not install an active run handle when turn start resolves after abort", async () => {
|
||||
let resolveTurnStart: ((value: ReturnType<typeof turnStartResult>) => void) | undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return await new Promise<ReturnType<typeof turnStartResult>>((resolve) => {
|
||||
resolveTurnStart = resolve;
|
||||
});
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
}) as never,
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.abortSignal = abortController.signal;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(
|
||||
() => expect(request.mock.calls.map(([method]) => method)).toContain("turn/start"),
|
||||
fastWait,
|
||||
);
|
||||
abortController.abort("test_abort");
|
||||
resolveTurnStart?.(turnStartResult());
|
||||
|
||||
await expect(run).rejects.toThrow("test_abort");
|
||||
expect(queueActiveRunMessageForTest("session-1", "after abort")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps extended history enabled when resuming a bound Codex thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -443,17 +443,26 @@ export async function runCodexAppServerAttempt(
|
||||
|
||||
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId);
|
||||
preDynamicStartupStages.mark("session-agent");
|
||||
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
|
||||
? params.contextEngine
|
||||
: undefined;
|
||||
const isInactiveThreadBootstrapBinding = (binding: CodexAppServerThreadBinding | undefined) =>
|
||||
!activeContextEngine && binding?.contextEngine?.projection?.mode === "thread_bootstrap";
|
||||
let startupBinding = await readCodexAppServerBinding(params.sessionFile);
|
||||
preDynamicStartupStages.mark("read-binding");
|
||||
const startupBindingAuthProfileId = startupBinding?.authProfileId;
|
||||
const initialStartupBindingHadInactiveThreadBootstrap =
|
||||
isInactiveThreadBootstrapBinding(startupBinding);
|
||||
startupBinding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: startupBinding,
|
||||
sessionFile: params.sessionFile,
|
||||
agentDir,
|
||||
codexHome: appServer.start.env?.CODEX_HOME,
|
||||
config: params.config,
|
||||
contextEngineActive: isActiveHarnessContextEngine(params.contextEngine),
|
||||
contextEngineActive: Boolean(activeContextEngine),
|
||||
});
|
||||
const initialInactiveThreadBootstrapBindingForcedFreshStart =
|
||||
initialStartupBindingHadInactiveThreadBootstrap && !startupBinding?.threadId;
|
||||
preDynamicStartupStages.mark("rotate-binding");
|
||||
const startupAuthProfileCandidate =
|
||||
params.runtimePlan?.auth.forwardedAuthProfileId ??
|
||||
@@ -520,9 +529,6 @@ export async function runCodexAppServerAttempt(
|
||||
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
|
||||
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
|
||||
? params.contextEngine
|
||||
: undefined;
|
||||
if (activeContextEngine) {
|
||||
assertContextEngineHostSupport({
|
||||
contextEngine: activeContextEngine,
|
||||
@@ -684,6 +690,8 @@ export async function runCodexAppServerAttempt(
|
||||
let contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
|
||||
let precomputedStaleBindingContinuityProjectionApplied = false;
|
||||
let staleBindingContinuityForcedFreshStart = false;
|
||||
let inactiveThreadBootstrapBindingForcedFreshStart =
|
||||
initialInactiveThreadBootstrapBindingForcedFreshStart;
|
||||
const applyFreshThreadContinuityProjection = () => {
|
||||
const projection = projectContextEngineAssemblyForCodex({
|
||||
assembledMessages: historyMessages,
|
||||
@@ -875,6 +883,10 @@ export async function runCodexAppServerAttempt(
|
||||
if (activeContextEngine || !binding?.threadId) {
|
||||
return false;
|
||||
}
|
||||
if (isInactiveThreadBootstrapBinding(binding)) {
|
||||
inactiveThreadBootstrapBindingForcedFreshStart = true;
|
||||
return false;
|
||||
}
|
||||
const projected = applyResumeStaleBindingContinuityProjection(binding);
|
||||
precomputedStaleBindingContinuityProjectionApplied = projected;
|
||||
return projected;
|
||||
@@ -892,6 +904,12 @@ export async function runCodexAppServerAttempt(
|
||||
if (action === "started" && staleBindingContinuityForcedFreshStart) {
|
||||
return true;
|
||||
}
|
||||
if (action === "started" && inactiveThreadBootstrapBindingForcedFreshStart) {
|
||||
// A retired thread-bootstrap context engine already forced Codex onto a
|
||||
// clean native thread; without that engine active, mirrored history would
|
||||
// re-inject stale bootstrap context as a new user turn.
|
||||
return false;
|
||||
}
|
||||
if (action === "resumed" && binding) {
|
||||
return applyResumeStaleBindingContinuityProjection(binding);
|
||||
}
|
||||
@@ -909,6 +927,7 @@ export async function runCodexAppServerAttempt(
|
||||
return;
|
||||
}
|
||||
const previousThreadId = startupBinding.threadId;
|
||||
const hadInactiveThreadBootstrapBinding = isInactiveThreadBootstrapBinding(startupBinding);
|
||||
const projectedTurnTokens = estimateCodexAppServerProjectedTurnTokens({
|
||||
prompt: codexTurnPromptText,
|
||||
developerInstructions: buildRenderedCodexDeveloperInstructions(),
|
||||
@@ -925,7 +944,10 @@ export async function runCodexAppServerAttempt(
|
||||
if (startupBinding?.threadId) {
|
||||
return;
|
||||
}
|
||||
staleBindingContinuityForcedFreshStart = precomputedStaleBindingContinuityProjectionApplied;
|
||||
inactiveThreadBootstrapBindingForcedFreshStart = hadInactiveThreadBootstrapBinding;
|
||||
staleBindingContinuityForcedFreshStart =
|
||||
precomputedStaleBindingContinuityProjectionApplied &&
|
||||
!inactiveThreadBootstrapBindingForcedFreshStart;
|
||||
if (activeContextEngine) {
|
||||
contextEngineProjection = undefined;
|
||||
try {
|
||||
@@ -1810,6 +1832,22 @@ export async function runCodexAppServerAttempt(
|
||||
});
|
||||
|
||||
let turn: CodexTurnStartResponse | undefined;
|
||||
const throwIfTurnStartAcceptedAfterAbort = () => {
|
||||
if (!runAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const reason = runAbortController.signal.reason;
|
||||
if (reason instanceof Error) {
|
||||
throw reason;
|
||||
}
|
||||
const error = new Error(
|
||||
typeof reason === "string" && reason.length > 0
|
||||
? reason
|
||||
: "codex app-server turn start aborted before acceptance",
|
||||
);
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
};
|
||||
const startCodexTurn = async (): Promise<CodexTurnStartResponse> => {
|
||||
const turnStartParams = buildTurnStartParams(params, {
|
||||
threadId: thread.threadId,
|
||||
@@ -1825,12 +1863,14 @@ export async function runCodexAppServerAttempt(
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
});
|
||||
codexModelCallDiagnostics.setRequestPayloadBytes(utf8JsonByteLength(turnStartParams));
|
||||
return assertCodexTurnStartResponse(
|
||||
const startedTurn = assertCodexTurnStartResponse(
|
||||
await client.request("turn/start", turnStartParams, {
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
}),
|
||||
);
|
||||
throwIfTurnStartAcceptedAfterAbort();
|
||||
return startedTurn;
|
||||
};
|
||||
try {
|
||||
codexModelCallDiagnostics.emitStarted();
|
||||
@@ -2101,7 +2141,7 @@ export async function runCodexAppServerAttempt(
|
||||
kind: "embedded" as const,
|
||||
queueMessage: async (text: string, optionsLocal?: CodexSteeringQueueOptions) =>
|
||||
activeSteeringQueue.queue(text, optionsLocal),
|
||||
isStreaming: () => !completed,
|
||||
isStreaming: () => !completed && !runAbortController.signal.aborted,
|
||||
isCompacting: () => projectorRef.current?.isCompacting() ?? false,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
cancel: () => runAbortController.abort("cancelled"),
|
||||
|
||||
@@ -133,8 +133,8 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
afterEach(() => {
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
mocks.bridgeCodexAppServerStartOptions.mockClear();
|
||||
mocks.applyCodexAppServerAuthProfile.mockClear();
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();
|
||||
|
||||
@@ -262,7 +262,12 @@ export async function createIsolatedCodexAppServerClient(
|
||||
|
||||
export function resetSharedCodexAppServerClientForTests(): void {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const clients = collectSharedClients(state);
|
||||
state.clients.clear();
|
||||
state.leasedReleases = new WeakMap();
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClient(): void {
|
||||
|
||||
@@ -2,4 +2,4 @@ export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
|
||||
export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
|
||||
// Keep this in sync with the Codex CLI live-test package pin.
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.134.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.135.0";
|
||||
|
||||
@@ -186,6 +186,7 @@ describe("codex conversation turn collector", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
@@ -206,6 +207,8 @@ describe("codex conversation turn collector", () => {
|
||||
|
||||
await expect(completion).resolves.toEqual({ replyText: "" });
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CopilotClientPool } from "./harness.js";
|
||||
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runCopilotAttempt: vi.fn(),
|
||||
resolvePoolAcquire: vi.fn(
|
||||
() =>
|
||||
({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "useLoggedInUser",
|
||||
copilotHome: "/tmp/copilot",
|
||||
},
|
||||
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
}) as any,
|
||||
),
|
||||
createCopilotClientPool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./src/attempt.js", () => ({
|
||||
resolvePoolAcquire: mocks.resolvePoolAcquire,
|
||||
runCopilotAttempt: mocks.runCopilotAttempt,
|
||||
}));
|
||||
|
||||
@@ -20,6 +30,12 @@ vi.mock("./src/runtime.js", () => ({
|
||||
|
||||
const ATTEMPT_PARAMS = { provider: "github-copilot", model: "gpt-4.1" } as any;
|
||||
const ATTEMPT_RESULT = { ok: true } as any;
|
||||
const TEST_SESSION_CONFIG = {
|
||||
availableTools: [],
|
||||
model: "gpt-4.1",
|
||||
tools: [],
|
||||
workingDirectory: "/workspace",
|
||||
};
|
||||
|
||||
function makePoolMock(): CopilotClientPool {
|
||||
return {
|
||||
@@ -63,8 +79,18 @@ async function flushAsyncWork() {
|
||||
describe("createCopilotAgentHarness", () => {
|
||||
beforeEach(() => {
|
||||
mocks.runCopilotAttempt.mockReset();
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
mocks.createCopilotClientPool.mockReset();
|
||||
mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
|
||||
mocks.resolvePoolAcquire.mockReturnValue({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "useLoggedInUser",
|
||||
copilotHome: "/tmp/copilot",
|
||||
},
|
||||
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
});
|
||||
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
|
||||
});
|
||||
|
||||
@@ -504,7 +530,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
function makeAttemptParams(overrides: Record<string, unknown> = {}): any {
|
||||
return {
|
||||
provider: "github-copilot",
|
||||
model: { provider: "github-copilot", id: "gpt-4.1" },
|
||||
model: "gpt-4.1",
|
||||
cwd: "/ws",
|
||||
workspaceDir: "/ws",
|
||||
agentDir: "/home",
|
||||
@@ -585,6 +611,36 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not seed when compatibility fingerprint differs (model API change)", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-api",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
runId: "t1",
|
||||
model: { api: "chat", provider: "github-copilot", id: "gpt-4.1" },
|
||||
}),
|
||||
);
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
runId: "t2",
|
||||
model: { api: "responses", provider: "github-copilot", id: "gpt-4.1" },
|
||||
}),
|
||||
);
|
||||
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not seed when compatibility fingerprint differs (legacy auth.gitHubToken rotation)", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
@@ -779,7 +835,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(sessionStore.store.register).toHaveBeenCalledWith(
|
||||
"oc-sess-reuse",
|
||||
expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
sdkSessionId: "sdk-sess-sqlite",
|
||||
}),
|
||||
);
|
||||
@@ -789,6 +845,45 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite");
|
||||
});
|
||||
|
||||
it("resumes shipped schema v1 plugin-state bindings for attempts", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-current",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const firstHarness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
|
||||
await firstHarness.runAttempt(makeAttemptParams({ runId: "t1" }));
|
||||
const stored = sessionStore.entries.get("oc-sess-reuse");
|
||||
if (!stored) {
|
||||
throw new Error("expected persisted binding");
|
||||
}
|
||||
sessionStore.entries.set("oc-sess-reuse", {
|
||||
schemaVersion: 1,
|
||||
sdkSessionId: "sdk-sess-v1",
|
||||
compatKey: stored.compatKey,
|
||||
updatedAt: Date.now(),
|
||||
} as never);
|
||||
mocks.runCopilotAttempt.mockClear();
|
||||
const secondHarness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
|
||||
await secondHarness.runAttempt(makeAttemptParams({ runId: "t2" }));
|
||||
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[0]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-v1");
|
||||
});
|
||||
|
||||
it("starts a fresh SDK session when persisted binding lookup fails", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
sessionStore.store.lookup.mockImplementation(() => {
|
||||
@@ -814,9 +909,11 @@ describe("createCopilotAgentHarness", () => {
|
||||
it("keeps the in-memory binding when durable register fails", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
sessionStore.entries.set("oc-sess-reuse", {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
sdkSessionId: "sdk-sess-stale",
|
||||
compatKey: "stale",
|
||||
compactKey: "stale",
|
||||
authMode: "useLoggedInUser",
|
||||
updatedAt: 1,
|
||||
});
|
||||
sessionStore.store.register.mockImplementation(() => {
|
||||
@@ -962,9 +1059,11 @@ describe("createCopilotAgentHarness", () => {
|
||||
it("deletes persisted sdkSessionId on reset even when no in-memory client is tracked", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
sessionStore.entries.set("oc-sess-reuse", {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
sdkSessionId: "sdk-sess-orphan",
|
||||
compatKey: "compat",
|
||||
compactKey: "compat",
|
||||
authMode: "useLoggedInUser",
|
||||
updatedAt: 1,
|
||||
});
|
||||
const harness = createCopilotAgentHarness({
|
||||
@@ -1038,6 +1137,20 @@ describe("createCopilotAgentHarness", () => {
|
||||
});
|
||||
|
||||
describe("compact", () => {
|
||||
function makeCompactParams(overrides: Record<string, unknown> = {}): any {
|
||||
return {
|
||||
provider: "github-copilot",
|
||||
model: { provider: "github-copilot", id: "gpt-4.1" },
|
||||
cwd: "/ws",
|
||||
workspaceDir: "/ws",
|
||||
agentDir: "/home",
|
||||
copilotHome: "/copilot-home",
|
||||
auth: { useLoggedInUser: true },
|
||||
sessionId: "oc-sess-compact",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns ok:false when sessionId is missing", async () => {
|
||||
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
|
||||
const result = await harness.compact?.({ workspaceDir: "/ws" } as any);
|
||||
@@ -1048,124 +1161,667 @@ describe("createCopilotAgentHarness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ok:false when workspaceDir is missing", async () => {
|
||||
it("returns ok:false when the SDK session is not tracked", async () => {
|
||||
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
|
||||
const result = await harness.compact?.({ sessionId: "s" } as any);
|
||||
const result = await harness.compact?.({
|
||||
sessionId: "oc-sess-compact-1",
|
||||
trigger: "budget",
|
||||
currentTokenCount: 12345,
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing-required-params",
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes an OpenClaw marker under <workspaceDir>/files and returns ok:true,compacted:false", async () => {
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-"));
|
||||
try {
|
||||
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
|
||||
const result = await harness.compact?.({
|
||||
it("calls the SDK history compaction RPC without requiring a workspace sidecar", async () => {
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
}));
|
||||
const disconnect = vi.fn(async () => {
|
||||
throw new Error("disconnect failed");
|
||||
});
|
||||
const resumeSession = vi.fn(async () => ({
|
||||
disconnect,
|
||||
rpc: { history: { compact } },
|
||||
}));
|
||||
const pool = makePoolMock();
|
||||
pool.acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
const release = vi.fn(async () => undefined);
|
||||
pool.release = release;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-compact",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeCompactParams({
|
||||
agentId: "main",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
workspaceDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 12345,
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "deferred-to-sdk-infinite-sessions",
|
||||
});
|
||||
|
||||
const files = await readdir(join(workspaceDir, "files"));
|
||||
const marker = files.find((f) => f.startsWith("openclaw-compaction-"));
|
||||
expect(marker).toBeDefined();
|
||||
expect(marker).toMatch(/openclaw-compaction-\d+-oc-sess-compact-1\.json/);
|
||||
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker!), "utf8"));
|
||||
expect(contents).toMatchObject({
|
||||
version: 1,
|
||||
source: "copilot-harness",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
compacted: false,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 12345,
|
||||
reason: "deferred-to-sdk-infinite-sessions",
|
||||
});
|
||||
} finally {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("records the tracked sdkSessionId in the marker when an attempt has run", async () => {
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-tracked-"));
|
||||
try {
|
||||
const pool = makePoolMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-tracked",
|
||||
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-tracked" });
|
||||
await harness.compact?.({
|
||||
sessionId: "oc-sess-tracked",
|
||||
workspaceDir,
|
||||
trigger: "manual",
|
||||
} as any);
|
||||
|
||||
const files = await readdir(join(workspaceDir, "files"));
|
||||
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
|
||||
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
|
||||
expect(contents.sdkSessionId).toBe("sdk-sess-tracked");
|
||||
} finally {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("records force:true in the marker and surfaces a force-specific reason", async () => {
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-force-"));
|
||||
try {
|
||||
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
|
||||
const result = await harness.compact?.({
|
||||
sessionId: "oc-sess-force",
|
||||
workspaceDir,
|
||||
force: true,
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
|
||||
});
|
||||
|
||||
const files = await readdir(join(workspaceDir, "files"));
|
||||
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
|
||||
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
|
||||
expect(contents.force).toBe(true);
|
||||
expect(contents.reason).toBe("force-requested-but-sdk-has-no-synchronous-compact-api");
|
||||
} finally {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns ok:false with structured failure when the marker write throws", async () => {
|
||||
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
|
||||
// Use a path with a NUL character which Node rejects synchronously
|
||||
// on every platform, simulating a write failure that the harness
|
||||
// must convert into a structured failure instead of throwing.
|
||||
const badWorkspace = "/this\u0000is/illegal";
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
const result = await harness.compact?.({
|
||||
sessionId: "oc-sess-bad",
|
||||
workspaceDir: badWorkspace,
|
||||
} as any);
|
||||
...makeCompactParams({ sessionId: "oc-sess-compact-1" }),
|
||||
model: "gpt-4.1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
workspaceDir: "/this\u0000is/illegal",
|
||||
customInstructions: "Keep decisions.",
|
||||
});
|
||||
|
||||
expect(result?.ok).toBe(false);
|
||||
expect(result?.compacted).toBe(false);
|
||||
expect(result?.reason).toBe("marker-write-failed");
|
||||
expect(result?.failure?.reason).toBe("marker-write-failed");
|
||||
expect(typeof result?.failure?.rawError).toBe("string");
|
||||
expect(result?.failure?.rawError?.length ?? 0).toBeGreaterThan(0);
|
||||
expect(resumeSession).toHaveBeenCalledWith(
|
||||
"sdk-sess-compact",
|
||||
expect.objectContaining({
|
||||
availableTools: [],
|
||||
continuePendingWork: false,
|
||||
model: "gpt-4.1",
|
||||
suppressResumeEvent: true,
|
||||
tools: [],
|
||||
workingDirectory: "/workspace",
|
||||
}),
|
||||
);
|
||||
expect(compact).toHaveBeenCalledWith({ customInstructions: "Keep decisions." });
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: "copilot-sdk-history-compacted",
|
||||
});
|
||||
});
|
||||
|
||||
it("disconnects the resumed SDK session when compact aborts after resume", async () => {
|
||||
const abortController = new AbortController();
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
}));
|
||||
const disconnect = vi.fn(async () => undefined);
|
||||
const resumeSession = vi.fn(async () => {
|
||||
abortController.abort(new Error("stop compact"));
|
||||
return {
|
||||
disconnect,
|
||||
rpc: { history: { compact } },
|
||||
};
|
||||
});
|
||||
const pool = makePoolMock();
|
||||
pool.acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
const release = vi.fn(async () => undefined);
|
||||
pool.release = release;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-abort",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeCompactParams({
|
||||
agentId: "main",
|
||||
sessionId: "oc-sess-abort",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
const result = await harness.compact?.({
|
||||
...makeCompactParams({ sessionId: "oc-sess-abort" }),
|
||||
abortSignal: abortController.signal,
|
||||
model: "gpt-4.1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "oc-sess-abort",
|
||||
});
|
||||
|
||||
expect(resumeSession).toHaveBeenCalledTimes(1);
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
failure: {
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
rawError: "stop compact",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires matching token auth before compacting a tracked token-auth SDK session", async () => {
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 45,
|
||||
messagesRemoved: 2,
|
||||
}));
|
||||
const resumeSession = vi.fn(async () => ({
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
rpc: { history: { compact } },
|
||||
}));
|
||||
const pool = makePoolMock();
|
||||
const acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.acquire = acquire;
|
||||
pool.release = vi.fn(async () => undefined);
|
||||
mocks.resolvePoolAcquire
|
||||
.mockReturnValueOnce({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "gitHubToken",
|
||||
authProfileId: "p1",
|
||||
authProfileVersion: "v1",
|
||||
copilotHome: "/copilot-home",
|
||||
gitHubToken: "ghp_test",
|
||||
},
|
||||
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "useLoggedInUser",
|
||||
copilotHome: "/copilot-home",
|
||||
},
|
||||
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home", useLoggedInUser: true },
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "gitHubToken",
|
||||
authProfileId: "p1",
|
||||
authProfileVersion: "v1",
|
||||
copilotHome: "/copilot-home",
|
||||
gitHubToken: "ghp_test",
|
||||
},
|
||||
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
|
||||
});
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-token",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeCompactParams({
|
||||
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
|
||||
sessionId: "oc-sess-token",
|
||||
}),
|
||||
);
|
||||
const result = await harness.compact?.(
|
||||
makeCompactParams({
|
||||
auth: undefined,
|
||||
sessionId: "oc-sess-token",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
|
||||
const matchingResult = await harness.compact?.(
|
||||
makeCompactParams({
|
||||
auth: undefined,
|
||||
authProfileId: "p1",
|
||||
resolvedApiKey: "ghp_test",
|
||||
sessionId: "oc-sess-token",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resumeSession).toHaveBeenCalledWith(
|
||||
"sdk-sess-token",
|
||||
expect.objectContaining({
|
||||
continuePendingWork: false,
|
||||
gitHubToken: "ghp_test",
|
||||
model: "gpt-4.1",
|
||||
suppressResumeEvent: true,
|
||||
workingDirectory: "/workspace",
|
||||
}),
|
||||
);
|
||||
expect(matchingResult?.compacted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not compact a tracked SDK session after model changes", async () => {
|
||||
const resumeSession = vi.fn();
|
||||
const pool = makePoolMock();
|
||||
const acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.acquire = acquire;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-model",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-model" }));
|
||||
const result = await harness.compact?.(
|
||||
makeCompactParams({ model: "gpt-5", sessionId: "oc-sess-model" }),
|
||||
);
|
||||
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not compact a logged-in-user SDK session for a token-auth compact request", async () => {
|
||||
const resumeSession = vi.fn();
|
||||
const pool = makePoolMock();
|
||||
pool.acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-login",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-login" }));
|
||||
mocks.resolvePoolAcquire.mockReturnValueOnce({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "gitHubToken",
|
||||
authProfileId: "p1",
|
||||
authProfileVersion: "v1",
|
||||
copilotHome: "/copilot-home",
|
||||
gitHubToken: "ghp_test",
|
||||
},
|
||||
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
|
||||
});
|
||||
const result = await harness.compact?.(
|
||||
makeCompactParams({
|
||||
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
|
||||
sessionId: "oc-sess-login",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies missing SDK sessions as stale bindings for host recovery", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
const resumeSession = vi.fn(async () => {
|
||||
throw new Error("session not found");
|
||||
});
|
||||
const pool = makePoolMock();
|
||||
pool.acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.release = vi.fn(async () => undefined);
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-stale",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
|
||||
|
||||
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-stale" }));
|
||||
const result = await harness.compact?.(makeCompactParams({ sessionId: "oc-sess-stale" }));
|
||||
|
||||
expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-stale");
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "stale_thread_binding",
|
||||
failure: { reason: "stale_thread_binding", rawError: "session not found" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not start SDK compaction when the compact call is already aborted", async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort(new Error("caller canceled"));
|
||||
const resumeSession = vi.fn();
|
||||
const pool = makePoolMock();
|
||||
pool.acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.release = vi.fn(async () => undefined);
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-abort",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-abort" }));
|
||||
const result = await harness.compact?.(
|
||||
makeCompactParams({ abortSignal: abort.signal, sessionId: "oc-sess-abort" }),
|
||||
);
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
failure: {
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
rawError: "caller canceled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts the SDK manual history compaction when the compact call is canceled", async () => {
|
||||
const abort = new AbortController();
|
||||
let rejectCompact: ((reason?: unknown) => void) | undefined;
|
||||
const compact = vi.fn(
|
||||
() =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectCompact = reject;
|
||||
}),
|
||||
);
|
||||
const abortManualCompaction = vi.fn(async () => {
|
||||
rejectCompact?.(new Error("manual compaction aborted"));
|
||||
return { aborted: true };
|
||||
});
|
||||
const disconnect = vi.fn(async () => undefined);
|
||||
const resumeSession = vi.fn(async () => ({
|
||||
disconnect,
|
||||
rpc: { history: { abortManualCompaction, compact } },
|
||||
}));
|
||||
const pool = makePoolMock();
|
||||
pool.acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.release = vi.fn(async () => undefined);
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-cancel",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-cancel" }));
|
||||
const resultPromise = harness.compact?.(
|
||||
makeCompactParams({ abortSignal: abort.signal, sessionId: "oc-sess-cancel" }),
|
||||
);
|
||||
await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1));
|
||||
abort.abort(new Error("caller canceled"));
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(abortManualCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
failure: {
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
rawError: "caller canceled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses persisted token-auth bindings without matching token auth", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
mocks.resolvePoolAcquire.mockReturnValueOnce({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "gitHubToken",
|
||||
authProfileId: "p1",
|
||||
authProfileVersion: "v1",
|
||||
copilotHome: "/copilot-home",
|
||||
gitHubToken: "ghp_test",
|
||||
},
|
||||
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
|
||||
});
|
||||
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-persisted-token",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const firstHarness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
await firstHarness.runAttempt(
|
||||
makeCompactParams({
|
||||
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
|
||||
sessionId: "oc-sess-persisted-token",
|
||||
}),
|
||||
);
|
||||
|
||||
const resumeSession = vi.fn();
|
||||
const secondPool = makePoolMock();
|
||||
const secondAcquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
secondPool.acquire = secondAcquire;
|
||||
const secondHarness = createCopilotAgentHarness({
|
||||
pool: secondPool,
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
const result = await secondHarness.compact?.(
|
||||
makeCompactParams({ auth: undefined, sessionId: "oc-sess-persisted-token" }),
|
||||
);
|
||||
|
||||
expect(secondAcquire).not.toHaveBeenCalled();
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
|
||||
mocks.resolvePoolAcquire.mockReturnValueOnce({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "gitHubToken",
|
||||
authProfileId: "p1",
|
||||
authProfileVersion: "v2",
|
||||
copilotHome: "/copilot-home",
|
||||
gitHubToken: "ghp_other",
|
||||
},
|
||||
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_other" },
|
||||
});
|
||||
const rotatedPool = makePoolMock();
|
||||
const rotatedAcquire = vi.fn();
|
||||
rotatedPool.acquire = rotatedAcquire;
|
||||
const rotatedHarness = createCopilotAgentHarness({
|
||||
pool: rotatedPool,
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
const rotatedResult = await rotatedHarness.compact?.(
|
||||
makeCompactParams({
|
||||
auth: { gitHubToken: "ghp_other", profileId: "p1", profileVersion: "v2" },
|
||||
sessionId: "oc-sess-persisted-token",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(rotatedAcquire).not.toHaveBeenCalled();
|
||||
expect(rotatedResult).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not compact a persisted SDK binding after harness restart", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
const firstHarness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-persisted",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
await firstHarness.runAttempt(makeCompactParams({ sessionId: "oc-sess-persisted" }));
|
||||
|
||||
const resumeSession = vi.fn();
|
||||
const secondPool = makePoolMock();
|
||||
const secondAcquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
secondPool.acquire = secondAcquire;
|
||||
secondPool.release = vi.fn(async () => undefined);
|
||||
const secondHarness = createCopilotAgentHarness({
|
||||
pool: secondPool,
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
|
||||
const result = await secondHarness.compact?.(
|
||||
makeCompactParams({ sessionId: "oc-sess-persisted" }),
|
||||
);
|
||||
|
||||
expect(secondAcquire).not.toHaveBeenCalled();
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(sessionStore.store.delete).not.toHaveBeenCalledWith("oc-sess-persisted");
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports SDK history compaction no-ops without writing compatibility state", async () => {
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 0,
|
||||
messagesRemoved: 0,
|
||||
}));
|
||||
const resumeSession = vi.fn(async () => ({
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
rpc: { history: { compact } },
|
||||
}));
|
||||
const pool = makePoolMock();
|
||||
pool.acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.release = vi.fn(async () => undefined);
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-noop",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-noop" }));
|
||||
const result = await harness.compact?.({
|
||||
...makeCompactParams({ sessionId: "oc-sess-noop" }),
|
||||
sessionId: "oc-sess-noop",
|
||||
workspaceDir: "/this\u0000is/illegal",
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledWith(undefined);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "already under target",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import type { CopilotClient } from "@github/copilot-sdk";
|
||||
import type {
|
||||
AgentHarness,
|
||||
AgentHarnessAttemptParams,
|
||||
AgentHarnessAttemptResult,
|
||||
AgentHarnessCompactParams,
|
||||
AgentHarnessCompactResult,
|
||||
AgentHarnessResetParams,
|
||||
import {
|
||||
compactWithSafetyTimeout,
|
||||
resolveCompactionTimeoutMs,
|
||||
type AgentHarness,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
type AgentHarnessCompactParams,
|
||||
type AgentHarnessCompactResult,
|
||||
type AgentHarnessResetParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type { CopilotSessionConfig } from "./src/attempt.js";
|
||||
import { resolveCopilotAuth } from "./src/auth-bridge.js";
|
||||
import { writeOpenClawCompactionMarker } from "./src/compaction-bridge.js";
|
||||
import type { CopilotClientPool, CopilotClientPoolOptions, PooledClient } from "./src/runtime.js";
|
||||
import type {
|
||||
ClientCreateOptions,
|
||||
CopilotClientPool,
|
||||
CopilotClientPoolOptions,
|
||||
PooledClient,
|
||||
PoolKey,
|
||||
} from "./src/runtime.js";
|
||||
|
||||
export type { CopilotClientPool, CopilotClientPoolOptions };
|
||||
|
||||
@@ -28,6 +36,9 @@ export interface CreateCopilotAgentHarnessOptions {
|
||||
interface TrackedSession {
|
||||
sdkSessionId: string;
|
||||
client: CopilotClient;
|
||||
clientOptions: ClientCreateOptions;
|
||||
poolKey: PoolKey;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
// Compatibility fingerprint of the params that created the SDK
|
||||
// session. We only reuse the tracked SDK session when the next
|
||||
// attempt's fingerprint matches — different provider/model/cwd/auth
|
||||
@@ -36,49 +47,153 @@ interface TrackedSession {
|
||||
// `createSession` (no resume injection) and the new sdkSessionId
|
||||
// replaces this entry via `onSessionEstablished`.
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
}
|
||||
|
||||
interface CopilotHistoryCompactResult {
|
||||
success: boolean;
|
||||
tokensRemoved: number;
|
||||
messagesRemoved: number;
|
||||
summaryContent?: string;
|
||||
}
|
||||
|
||||
interface CopilotHistoryCompactSession {
|
||||
abort(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
rpc: {
|
||||
history: {
|
||||
abortManualCompaction(): Promise<{ aborted: boolean }>;
|
||||
compact(params?: { customInstructions?: string }): Promise<CopilotHistoryCompactResult>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type CopilotSessionBinding = {
|
||||
schemaVersion: 2;
|
||||
sdkSessionId: string;
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type LegacyCopilotSessionBinding = {
|
||||
schemaVersion: 1;
|
||||
sdkSessionId: string;
|
||||
compatKey: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type CopilotAttemptSessionBinding = Pick<CopilotSessionBinding, "compatKey" | "sdkSessionId">;
|
||||
|
||||
type CopilotSessionBindingStore = Pick<
|
||||
PluginStateSyncKeyedStore<CopilotSessionBinding>,
|
||||
"delete" | "lookup" | "register"
|
||||
>;
|
||||
|
||||
type CopilotSessionAuth = Pick<
|
||||
CopilotSessionBinding,
|
||||
"authMode" | "authProfileId" | "authProfileVersion"
|
||||
>;
|
||||
|
||||
function sessionAuthFields(auth: CopilotSessionAuth): CopilotSessionAuth {
|
||||
return auth.authMode === "gitHubToken"
|
||||
? {
|
||||
authMode: "gitHubToken",
|
||||
authProfileId: auth.authProfileId,
|
||||
authProfileVersion: auth.authProfileVersion,
|
||||
}
|
||||
: { authMode: "useLoggedInUser" };
|
||||
}
|
||||
|
||||
function sessionAuthMatches(stored: CopilotSessionAuth, current: CopilotSessionAuth): boolean {
|
||||
if (stored.authMode !== current.authMode) {
|
||||
return false;
|
||||
}
|
||||
if (stored.authMode === "useLoggedInUser") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
current.authMode === "gitHubToken" &&
|
||||
stored.authProfileId === current.authProfileId &&
|
||||
stored.authProfileVersion === current.authProfileVersion
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBinding(
|
||||
value: CopilotSessionBinding | undefined,
|
||||
): CopilotSessionBinding | undefined {
|
||||
if (
|
||||
!value ||
|
||||
value.schemaVersion !== 1 ||
|
||||
value.schemaVersion !== 2 ||
|
||||
typeof value.sdkSessionId !== "string" ||
|
||||
value.sdkSessionId.trim() === "" ||
|
||||
typeof value.compatKey !== "string" ||
|
||||
value.compatKey.trim() === "" ||
|
||||
typeof value.compactKey !== "string" ||
|
||||
value.compactKey.trim() === "" ||
|
||||
(value.authMode !== "gitHubToken" && value.authMode !== "useLoggedInUser") ||
|
||||
(value.authMode === "gitHubToken" &&
|
||||
(typeof value.authProfileId !== "string" ||
|
||||
value.authProfileId.trim() === "" ||
|
||||
typeof value.authProfileVersion !== "string" ||
|
||||
value.authProfileVersion.trim() === "")) ||
|
||||
typeof value.updatedAt !== "number" ||
|
||||
!Number.isFinite(value.updatedAt)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
sdkSessionId: value.sdkSessionId.trim(),
|
||||
compatKey: value.compatKey,
|
||||
compactKey: value.compactKey,
|
||||
authMode: value.authMode,
|
||||
...(value.authMode === "gitHubToken"
|
||||
? {
|
||||
authProfileId: value.authProfileId,
|
||||
authProfileVersion: value.authProfileVersion,
|
||||
}
|
||||
: {}),
|
||||
updatedAt: value.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAttemptBinding(value: unknown): CopilotAttemptSessionBinding | undefined {
|
||||
const current = normalizeBinding(value as CopilotSessionBinding | undefined);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
const legacy = value as LegacyCopilotSessionBinding | undefined;
|
||||
if (
|
||||
!legacy ||
|
||||
legacy.schemaVersion !== 1 ||
|
||||
typeof legacy.sdkSessionId !== "string" ||
|
||||
legacy.sdkSessionId.trim() === "" ||
|
||||
typeof legacy.compatKey !== "string" ||
|
||||
legacy.compatKey.trim() === "" ||
|
||||
typeof legacy.updatedAt !== "number" ||
|
||||
!Number.isFinite(legacy.updatedAt)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sdkSessionId: legacy.sdkSessionId.trim(),
|
||||
compatKey: legacy.compatKey,
|
||||
};
|
||||
}
|
||||
|
||||
function lookupStoredBinding(
|
||||
store: CopilotSessionBindingStore | undefined,
|
||||
key: string,
|
||||
): CopilotSessionBinding | undefined {
|
||||
): CopilotAttemptSessionBinding | undefined {
|
||||
try {
|
||||
return normalizeBinding(store?.lookup(key));
|
||||
return normalizeAttemptBinding(store?.lookup(key));
|
||||
} catch {
|
||||
try {
|
||||
store?.delete(key);
|
||||
@@ -118,6 +233,58 @@ function deleteStoredBinding(store: CopilotSessionBindingStore | undefined, key:
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (!signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const reason = "reason" in signal ? signal.reason : undefined;
|
||||
if (reason instanceof Error) {
|
||||
throw reason;
|
||||
}
|
||||
const error = reason ? new Error("aborted", { cause: reason }) : new Error("aborted");
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
}
|
||||
|
||||
function isStaleSdkSessionError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /\b(404|not found|no such session|unknown session|stale|deleted|does not exist)\b/i.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
async function compactTrackedSdkSession(params: {
|
||||
abortSignal?: AbortSignal;
|
||||
client: CopilotClient;
|
||||
customInstructions?: string;
|
||||
gitHubToken?: string;
|
||||
onSession?: (session: CopilotHistoryCompactSession) => void;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
}): Promise<CopilotHistoryCompactResult> {
|
||||
throwIfAborted(params.abortSignal);
|
||||
const session = (await params.client.resumeSession(params.sdkSessionId, {
|
||||
...params.sessionConfig,
|
||||
continuePendingWork: false,
|
||||
...(params.gitHubToken ? { gitHubToken: params.gitHubToken } : {}),
|
||||
suppressResumeEvent: true,
|
||||
})) as unknown as CopilotHistoryCompactSession;
|
||||
params.onSession?.(session);
|
||||
const request = params.customInstructions?.trim()
|
||||
? { customInstructions: params.customInstructions }
|
||||
: undefined;
|
||||
try {
|
||||
throwIfAborted(params.abortSignal);
|
||||
return await session.rpc.history.compact(request);
|
||||
} finally {
|
||||
try {
|
||||
await session.disconnect();
|
||||
} catch {
|
||||
// Preserve the compaction or cancellation outcome; cleanup is best-effort here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a string fingerprint of the attempt params that must agree
|
||||
// across turns for SDK-session reuse to be safe. Keep this list
|
||||
// conservative: any field whose change would invalidate the SDK
|
||||
@@ -135,8 +302,21 @@ function deleteStoredBinding(store: CopilotSessionBindingStore | undefined, key:
|
||||
// the token (see `tokenFingerprint` in `src/auth-bridge.ts`), so
|
||||
// rotating the token under the same profile id still invalidates
|
||||
// the compat key without ever serializing the raw credential.
|
||||
function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
|
||||
const p = params as AgentHarnessAttemptParams & {
|
||||
type CopilotSessionCompatParams = AgentHarnessAttemptParams | AgentHarnessCompactParams;
|
||||
|
||||
function readAgentIdFromSessionKey(sessionKey: unknown): string | undefined {
|
||||
if (typeof sessionKey !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const parts = sessionKey.trim().split(":");
|
||||
return parts[0] === "agent" && parts[1]?.trim() ? parts[1].trim() : undefined;
|
||||
}
|
||||
|
||||
function computeSessionKey(
|
||||
params: CopilotSessionCompatParams,
|
||||
options: { includeApi: boolean; includeAuth: boolean },
|
||||
): string {
|
||||
const p = params as CopilotSessionCompatParams & {
|
||||
auth?: {
|
||||
gitHubToken?: string;
|
||||
profileId?: string;
|
||||
@@ -144,18 +324,26 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
|
||||
useLoggedInUser?: boolean;
|
||||
};
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
authProfileId?: string;
|
||||
copilotHome?: string;
|
||||
cwd?: string;
|
||||
modelId?: string;
|
||||
model?: string | { api?: string; id?: string; provider?: string };
|
||||
profileVersion?: string;
|
||||
resolvedApiKey?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
const modelObj: { api?: string; id?: string; provider?: string } =
|
||||
p.model && typeof p.model === "object"
|
||||
? p.model
|
||||
: { id: typeof p.model === "string" ? p.model : undefined };
|
||||
const provider = modelObj.provider ?? (typeof p.provider === "string" ? p.provider : "");
|
||||
const modelId =
|
||||
modelObj.id ??
|
||||
(typeof p.modelId === "string" ? p.modelId : undefined) ??
|
||||
(typeof p.model === "string" ? p.model : "");
|
||||
// resolveCopilotAuth can throw when an explicit `auth.gitHubToken`
|
||||
// is supplied without profileId + profileVersion (the existing
|
||||
// pool-key safety invariant). That same error would surface
|
||||
@@ -169,7 +357,7 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
|
||||
let resolvedCopilotHome = "";
|
||||
try {
|
||||
const resolved = resolveCopilotAuth({
|
||||
agentId: typeof p.agentId === "string" ? p.agentId : undefined,
|
||||
agentId: typeof p.agentId === "string" ? p.agentId : readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
@@ -189,19 +377,27 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
|
||||
authParts = ["auth=unresolvable"];
|
||||
}
|
||||
const parts = [
|
||||
`provider=${modelObj.provider ?? ""}`,
|
||||
`model=${modelObj.id ?? ""}`,
|
||||
`api=${modelObj.api ?? ""}`,
|
||||
`provider=${provider}`,
|
||||
`model=${modelId}`,
|
||||
...(options.includeApi ? [`api=${modelObj.api ?? ""}`] : []),
|
||||
`cwd=${p.cwd ?? p.workspaceDir ?? ""}`,
|
||||
`agentId=${resolvedAgentId}`,
|
||||
`agentDir=${p.agentDir ?? ""}`,
|
||||
`copilotHome=${p.copilotHome ?? ""}`,
|
||||
`resolvedCopilotHome=${resolvedCopilotHome}`,
|
||||
...authParts,
|
||||
...(options.includeAuth ? authParts : []),
|
||||
];
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
function computeSessionCompatKey(params: CopilotSessionCompatParams): string {
|
||||
return computeSessionKey(params, { includeApi: true, includeAuth: true });
|
||||
}
|
||||
|
||||
function computeSessionCompactKey(params: CopilotSessionCompatParams): string {
|
||||
return computeSessionKey(params, { includeApi: false, includeAuth: false });
|
||||
}
|
||||
|
||||
export function createCopilotAgentHarness(
|
||||
options?: CreateCopilotAgentHarnessOptions,
|
||||
): AgentHarness {
|
||||
@@ -257,10 +453,11 @@ export function createCopilotAgentHarness(
|
||||
if (disposed) {
|
||||
throw new Error("[copilot] harness has been disposed; cannot start new attempts");
|
||||
}
|
||||
const { runCopilotAttempt } = await import("./src/attempt.js");
|
||||
const { resolvePoolAcquire, runCopilotAttempt } = await import("./src/attempt.js");
|
||||
if (disposed) {
|
||||
throw new Error("[copilot] harness was disposed while starting an attempt");
|
||||
}
|
||||
const poolAcquire = resolvePoolAcquire(params as never);
|
||||
const pool = await getPool();
|
||||
if (disposed) {
|
||||
throw new Error("[copilot] harness was disposed while starting an attempt");
|
||||
@@ -289,6 +486,7 @@ export function createCopilotAgentHarness(
|
||||
// back to `createSession`, so a stale-session error never
|
||||
// surfaces as a prompt error.
|
||||
const currentCompatKey = computeSessionCompatKey(params);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined;
|
||||
const stored = openclawSessionId
|
||||
? resetBlockedStoredSessions.has(openclawSessionId)
|
||||
@@ -317,19 +515,28 @@ export function createCopilotAgentHarness(
|
||||
? ({
|
||||
sdkSessionId,
|
||||
pooledClient,
|
||||
sessionConfig,
|
||||
}: {
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
}) => {
|
||||
trackedSessions.set(openclawSessionId, {
|
||||
sdkSessionId,
|
||||
client: pooledClient.client,
|
||||
clientOptions: poolAcquire.options,
|
||||
compatKey: currentCompatKey,
|
||||
compactKey: currentCompactKey,
|
||||
poolKey: pooledClient.key,
|
||||
sessionConfig,
|
||||
...sessionAuthFields(poolAcquire.auth),
|
||||
});
|
||||
const persisted = registerStoredBinding(options?.sessionStore, openclawSessionId, {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
sdkSessionId,
|
||||
compatKey: currentCompatKey,
|
||||
compactKey: currentCompactKey,
|
||||
...sessionAuthFields(poolAcquire.auth),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (persisted) {
|
||||
@@ -376,20 +583,12 @@ export function createCopilotAgentHarness(
|
||||
async compact(
|
||||
params: AgentHarnessCompactParams,
|
||||
): Promise<AgentHarnessCompactResult | undefined> {
|
||||
// The GitHub Copilot agent runtime manages compaction automatically via
|
||||
// `SessionConfig.infiniteSessions` (background-async when
|
||||
// utilization crosses `backgroundCompactionThreshold`). There is
|
||||
// no synchronous compact RPC, so the harness cannot honour
|
||||
// `params.force === true` directly. Instead this method writes
|
||||
// an OpenClaw-shaped marker file under
|
||||
// `<workspaceDir>/files/openclaw-compaction-<ts>-<sessionId>.json`
|
||||
// so existing OpenClaw transcript readers see a familiar
|
||||
// compaction artifact when the host calls compact(). See
|
||||
// src/compaction-bridge.ts for the bridge boundary.
|
||||
// The SDK owns Copilot history compaction. OpenClaw only resumes
|
||||
// the tracked SDK session and calls the session-scoped RPC; durable
|
||||
// OpenClaw session/transcript state stays in SQLite, with no marker
|
||||
// sidecars under the workspace.
|
||||
const openclawSessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
|
||||
const workspaceDir =
|
||||
typeof params.workspaceDir === "string" ? params.workspaceDir : undefined;
|
||||
if (!openclawSessionId || !workspaceDir) {
|
||||
if (!openclawSessionId) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
@@ -397,34 +596,106 @@ export function createCopilotAgentHarness(
|
||||
};
|
||||
}
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
const reason = params.force
|
||||
? "force-requested-but-sdk-has-no-synchronous-compact-api"
|
||||
: "deferred-to-sdk-infinite-sessions";
|
||||
try {
|
||||
await writeOpenClawCompactionMarker({
|
||||
sessionId: openclawSessionId,
|
||||
workspaceDir,
|
||||
trigger: params.trigger,
|
||||
currentTokenCount: params.currentTokenCount,
|
||||
sdkSessionId: tracked?.sdkSessionId,
|
||||
force: params.force,
|
||||
reason,
|
||||
});
|
||||
} catch (err) {
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const { resolvePoolAcquire } = await import("./src/attempt.js");
|
||||
const resolvedPoolAcquire = resolvePoolAcquire(params as never);
|
||||
const currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
|
||||
const compatibleTracked =
|
||||
tracked?.compactKey === currentCompactKey && sessionAuthMatches(tracked, currentAuth)
|
||||
? tracked
|
||||
: undefined;
|
||||
if (!compatibleTracked) {
|
||||
// Durable bindings only carry SDK session ids. Manual SDK compaction also
|
||||
// needs the live SessionConfig with OpenClaw hooks/tools, so preserve the
|
||||
// binding for the next attempt and let the host compact transcript state.
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "marker-write-failed",
|
||||
failure: {
|
||||
reason: "marker-write-failed",
|
||||
rawError: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
};
|
||||
}
|
||||
const poolAcquire = compatibleTracked
|
||||
? { key: compatibleTracked.poolKey, options: compatibleTracked.clientOptions }
|
||||
: resolvedPoolAcquire;
|
||||
let compactResult: CopilotHistoryCompactResult;
|
||||
let handle: PooledClient | undefined;
|
||||
let pool: CopilotClientPool | undefined;
|
||||
let activeSdkSession: CopilotHistoryCompactSession | undefined;
|
||||
try {
|
||||
throwIfAborted(params.abortSignal);
|
||||
pool = await getPool();
|
||||
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
|
||||
const client = handle.client;
|
||||
compactResult = await compactWithSafetyTimeout(
|
||||
(abortSignal) =>
|
||||
compactTrackedSdkSession({
|
||||
abortSignal,
|
||||
client,
|
||||
customInstructions: params.customInstructions,
|
||||
gitHubToken:
|
||||
compatibleTracked?.clientOptions.gitHubToken ??
|
||||
(resolvedPoolAcquire.auth.authMode === "gitHubToken"
|
||||
? resolvedPoolAcquire.auth.gitHubToken
|
||||
: undefined),
|
||||
onSession: (session) => {
|
||||
activeSdkSession = session;
|
||||
},
|
||||
sessionConfig: compatibleTracked.sessionConfig,
|
||||
sdkSessionId: compatibleTracked.sdkSessionId,
|
||||
}),
|
||||
resolveCompactionTimeoutMs(
|
||||
(params as { config?: Parameters<typeof resolveCompactionTimeoutMs>[0] }).config,
|
||||
),
|
||||
{
|
||||
abortSignal: params.abortSignal,
|
||||
onCancel: () =>
|
||||
void activeSdkSession?.rpc.history.abortManualCompaction().catch(() => undefined),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const rawError = err instanceof Error ? err.message : String(err);
|
||||
if (isStaleSdkSessionError(err)) {
|
||||
trackedSessions.delete(openclawSessionId);
|
||||
deleteStoredBinding(options?.sessionStore, openclawSessionId);
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "stale_thread_binding",
|
||||
failure: { reason: "stale_thread_binding", rawError },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
failure: {
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
rawError,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (pool && handle) {
|
||||
try {
|
||||
await pool.release(handle);
|
||||
} catch {
|
||||
// Pool release failure must not mask the compaction outcome.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!compactResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "copilot-sdk-history-compact-failed",
|
||||
failure: { reason: "copilot-sdk-history-compact-failed" },
|
||||
};
|
||||
}
|
||||
const compacted = compactResult.tokensRemoved > 0 || compactResult.messagesRemoved > 0;
|
||||
return {
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason,
|
||||
compacted,
|
||||
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
4
extensions/copilot/npm-shrinkwrap.json
generated
4
extensions/copilot/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "copilot",
|
||||
"name": "GitHub Copilot agent runtime",
|
||||
"description": "Registers the GitHub Copilot agent runtime.",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
"onAgentHarnesses": ["copilot"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw GitHub Copilot agent runtime plugin (registers a `github-copilot` AgentHarness backed by @github/copilot-sdk over JSON-RPC to the GitHub Copilot CLI)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,10 +25,10 @@
|
||||
"minHostVersion": ">=2026.5.28"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31",
|
||||
"openclawVersion": "2026.6.1-alpha.3",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -50,6 +50,21 @@ const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
|
||||
|
||||
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
|
||||
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
|
||||
export type CopilotSessionConfig = Pick<
|
||||
SessionConfig,
|
||||
| "availableTools"
|
||||
| "enableSessionTelemetry"
|
||||
| "gitHubToken"
|
||||
| "hooks"
|
||||
| "instructionDirectories"
|
||||
| "infiniteSessions"
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
| "workingDirectory"
|
||||
>;
|
||||
// NOTE(plugin-sdk-widening): AttemptParamsLike can be removed once
|
||||
// openclaw/plugin-sdk/agent-harness-runtime declares auth, messages,
|
||||
// onAssistantDelta, and initialReplayState.sdkSessionId fields. Tracked by
|
||||
@@ -107,7 +122,11 @@ export interface CopilotAttemptDeps {
|
||||
* thrown from this callback are swallowed so they cannot break the
|
||||
* attempt.
|
||||
*/
|
||||
onSessionEstablished?: (info: { sdkSessionId: string; pooledClient: PooledClient }) => void;
|
||||
onSessionEstablished?: (info: {
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export async function runCopilotAttempt(
|
||||
@@ -415,7 +434,7 @@ export async function runCopilotAttempt(
|
||||
sessionIdUsed = sdkSessionId ?? input.sessionId;
|
||||
if (sdkSessionId && deps.onSessionEstablished) {
|
||||
try {
|
||||
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle });
|
||||
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle, sessionConfig });
|
||||
} catch {
|
||||
// never let session-tracking callbacks break attempts
|
||||
}
|
||||
@@ -714,21 +733,7 @@ function createSessionConfig(
|
||||
workspaceBootstrapInstructions: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
): Pick<
|
||||
SessionConfig,
|
||||
| "availableTools"
|
||||
| "enableSessionTelemetry"
|
||||
| "gitHubToken"
|
||||
| "hooks"
|
||||
| "instructionDirectories"
|
||||
| "infiniteSessions"
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
| "workingDirectory"
|
||||
> {
|
||||
): CopilotSessionConfig {
|
||||
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
|
||||
const hooks = createHooksBridge(params.hooksConfig);
|
||||
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInfiniteSessionConfig, writeOpenClawCompactionMarker } from "./compaction-bridge.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createInfiniteSessionConfig } from "./compaction-bridge.js";
|
||||
|
||||
describe("createInfiniteSessionConfig", () => {
|
||||
it("returns undefined when no options provided", () => {
|
||||
@@ -59,184 +56,3 @@ describe("createInfiniteSessionConfig", () => {
|
||||
expect(result).not.toHaveProperty("bufferExhaustionThreshold");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeOpenClawCompactionMarker", () => {
|
||||
it("writes a JSON marker with expected shape under <workspaceDir>/files", async () => {
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-compaction-"));
|
||||
try {
|
||||
const written = await writeOpenClawCompactionMarker(
|
||||
{
|
||||
sessionId: "openclaw-sess-123",
|
||||
workspaceDir,
|
||||
trigger: "manual",
|
||||
currentTokenCount: 42,
|
||||
sdkSessionId: "sdk-sess-abc",
|
||||
reason: "deferred-to-sdk-infinite-sessions",
|
||||
},
|
||||
{ now: () => 1_700_000_000_000 },
|
||||
);
|
||||
|
||||
expect(written.path).toBe(
|
||||
join(workspaceDir, "files", "openclaw-compaction-1700000000000-openclaw-sess-123.json"),
|
||||
);
|
||||
expect(written.marker).toEqual({
|
||||
version: 1,
|
||||
source: "copilot-harness",
|
||||
sessionId: "openclaw-sess-123",
|
||||
ts: 1_700_000_000_000,
|
||||
compacted: false,
|
||||
trigger: "manual",
|
||||
sdkSessionId: "sdk-sess-abc",
|
||||
currentTokenCount: 42,
|
||||
reason: "deferred-to-sdk-infinite-sessions",
|
||||
});
|
||||
|
||||
const contents = await readFile(written.path, "utf8");
|
||||
expect(contents.endsWith("\n")).toBe(true);
|
||||
expect(JSON.parse(contents)).toEqual(written.marker);
|
||||
} finally {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("records force:true in the marker without acting on it", async () => {
|
||||
const writes: Array<{ path: string; contents: string }> = [];
|
||||
const fs = {
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async (path: string, contents: string) => {
|
||||
writes.push({ path, contents });
|
||||
}),
|
||||
};
|
||||
|
||||
const written = await writeOpenClawCompactionMarker(
|
||||
{
|
||||
sessionId: "s1",
|
||||
workspaceDir: "/ws",
|
||||
force: true,
|
||||
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
|
||||
},
|
||||
{ now: () => 1, fs: fs as never },
|
||||
);
|
||||
|
||||
expect(written.marker.force).toBe(true);
|
||||
expect(written.marker.compacted).toBe(false);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(JSON.parse(writes[0].contents)).toMatchObject({ force: true });
|
||||
});
|
||||
|
||||
it("omits force / trigger / sdkSessionId / currentTokenCount when undefined", async () => {
|
||||
const writes: Array<{ path: string; contents: string }> = [];
|
||||
const fs = {
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async (path: string, contents: string) => {
|
||||
writes.push({ path, contents });
|
||||
}),
|
||||
};
|
||||
|
||||
const written = await writeOpenClawCompactionMarker(
|
||||
{ sessionId: "s1", workspaceDir: "/ws" },
|
||||
{ now: () => 7, fs: fs as never },
|
||||
);
|
||||
|
||||
expect(written.marker).toEqual({
|
||||
version: 1,
|
||||
source: "copilot-harness",
|
||||
sessionId: "s1",
|
||||
ts: 7,
|
||||
compacted: false,
|
||||
});
|
||||
const parsed = JSON.parse(writes[0].contents);
|
||||
expect(parsed).not.toHaveProperty("force");
|
||||
expect(parsed).not.toHaveProperty("trigger");
|
||||
expect(parsed).not.toHaveProperty("sdkSessionId");
|
||||
expect(parsed).not.toHaveProperty("currentTokenCount");
|
||||
expect(parsed).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("sanitizes sessionId chars in the filename", async () => {
|
||||
const fs = {
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
};
|
||||
const written = await writeOpenClawCompactionMarker(
|
||||
{ sessionId: "abc:/?\\@!def", workspaceDir: "/ws" },
|
||||
{ now: () => 1, fs: fs as never },
|
||||
);
|
||||
expect(written.path).toContain("openclaw-compaction-1-abc______def.json");
|
||||
// sessionId in the marker body stays the original unsanitized value.
|
||||
expect(written.marker.sessionId).toBe("abc:/?\\@!def");
|
||||
});
|
||||
|
||||
it("creates the subdir recursively before writing", async () => {
|
||||
const calls: Array<{ kind: "mkdir" | "write"; path: string; opts?: unknown }> = [];
|
||||
const fs = {
|
||||
mkdir: vi.fn(async (path: string, opts: unknown) => {
|
||||
calls.push({ kind: "mkdir", path, opts });
|
||||
}),
|
||||
writeFile: vi.fn(async (path: string) => {
|
||||
calls.push({ kind: "write", path });
|
||||
}),
|
||||
};
|
||||
await writeOpenClawCompactionMarker(
|
||||
{ sessionId: "s", workspaceDir: "/ws" },
|
||||
{ now: () => 1, fs: fs as never },
|
||||
);
|
||||
expect(calls[0]).toEqual({ kind: "mkdir", path: "/ws/files", opts: { recursive: true } });
|
||||
expect(calls[1]?.kind).toBe("write");
|
||||
});
|
||||
|
||||
it("honours a custom subdir option", async () => {
|
||||
const fs = {
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
};
|
||||
const written = await writeOpenClawCompactionMarker(
|
||||
{ sessionId: "s", workspaceDir: "/ws" },
|
||||
{ now: () => 1, fs: fs as never, subdir: "compaction" },
|
||||
);
|
||||
expect(written.path).toBe("/ws/compaction/openclaw-compaction-1-s.json");
|
||||
});
|
||||
|
||||
it("surfaces mkdir failures", async () => {
|
||||
const fs = {
|
||||
mkdir: vi.fn(async () => {
|
||||
throw new Error("EACCES");
|
||||
}),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
};
|
||||
await expect(
|
||||
writeOpenClawCompactionMarker(
|
||||
{ sessionId: "s", workspaceDir: "/ws" },
|
||||
{ now: () => 1, fs: fs as never },
|
||||
),
|
||||
).rejects.toThrow("EACCES");
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces writeFile failures", async () => {
|
||||
const fs = {
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => {
|
||||
throw new Error("ENOSPC");
|
||||
}),
|
||||
};
|
||||
await expect(
|
||||
writeOpenClawCompactionMarker(
|
||||
{ sessionId: "s", workspaceDir: "/ws" },
|
||||
{ now: () => 1, fs: fs as never },
|
||||
),
|
||||
).rejects.toThrow("ENOSPC");
|
||||
});
|
||||
|
||||
it("throws on missing sessionId", async () => {
|
||||
await expect(
|
||||
writeOpenClawCompactionMarker({ sessionId: "", workspaceDir: "/ws" }),
|
||||
).rejects.toThrow(/sessionId is required/);
|
||||
});
|
||||
|
||||
it("throws on missing workspaceDir", async () => {
|
||||
await expect(
|
||||
writeOpenClawCompactionMarker({ sessionId: "s", workspaceDir: "" }),
|
||||
).rejects.toThrow(/workspaceDir is required/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
|
||||
// Compaction bridge for the GitHub Copilot agent runtime.
|
||||
//
|
||||
// Two responsibilities:
|
||||
//
|
||||
// 1. Shape `SessionConfig.infiniteSessions` from a typed options bag
|
||||
// so attempt.ts can opt the SDK in to background auto-compaction
|
||||
// at session creation. The SDK manages the actual compaction
|
||||
// under the `infiniteSessions` config (background at
|
||||
// `backgroundCompactionThreshold`, blocking at
|
||||
// `bufferExhaustionThreshold`).
|
||||
//
|
||||
// 2. Write an OpenClaw-shaped JSON marker file at
|
||||
// `<workspaceDir>/files/openclaw-compaction-<sessionId>-<ts>.json`
|
||||
// whenever the host calls `harness.compact(params)`. Existing
|
||||
// OpenClaw transcript readers look in `workspacePath/files/` for
|
||||
// compaction artifacts; the marker keeps them informed even
|
||||
// though the SDK now owns the actual context-window mechanics
|
||||
// under infiniteSessions.
|
||||
// Shapes `SessionConfig.infiniteSessions` from a typed options bag so
|
||||
// attempt.ts can opt the SDK in to background auto-compaction at session
|
||||
// creation. The SDK manages the actual compaction under the `infiniteSessions`
|
||||
// config and the session-scoped history compaction RPC.
|
||||
//
|
||||
// Host back-pointers (NOT imported here to keep the package boundary
|
||||
// clean):
|
||||
@@ -64,120 +50,3 @@ export function createInfiniteSessionConfig(
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
export interface OpenClawCompactionMarkerInput {
|
||||
/** OpenClaw session id (CompactEmbeddedPiSessionParams.sessionId). */
|
||||
readonly sessionId: string;
|
||||
/** Workspace dir (CompactEmbeddedPiSessionParams.workspaceDir). */
|
||||
readonly workspaceDir: string;
|
||||
/** Compaction trigger from CompactEmbeddedPiSessionParams.trigger. */
|
||||
readonly trigger?: "budget" | "overflow" | "manual";
|
||||
/** Optional caller-observed token count at compaction time. */
|
||||
readonly currentTokenCount?: number;
|
||||
/** Optional active SDK session id when the marker is written. */
|
||||
readonly sdkSessionId?: string;
|
||||
/** Optional reason string for the marker. */
|
||||
readonly reason?: string;
|
||||
/**
|
||||
* Whether the host passed `force: true` in CompactEmbeddedPiSessionParams.
|
||||
* Recorded for diagnostics — the harness cannot synchronously force
|
||||
* compaction since the SDK has no on-demand compact RPC.
|
||||
*/
|
||||
readonly force?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenClawCompactionMarkerOptions {
|
||||
/** Override `Date.now`. Default: `Date.now`. */
|
||||
readonly now?: () => number;
|
||||
/** Override `node:fs/promises` writers. Useful in tests. */
|
||||
readonly fs?: Pick<typeof import("node:fs/promises"), "mkdir" | "writeFile">;
|
||||
/**
|
||||
* Subdirectory under workspaceDir that holds the markers. Default
|
||||
* `files` to match the proposal-defined location.
|
||||
*/
|
||||
readonly subdir?: string;
|
||||
}
|
||||
|
||||
export interface OpenClawCompactionMarker {
|
||||
readonly version: 1;
|
||||
readonly source: "copilot-harness";
|
||||
readonly sessionId: string;
|
||||
readonly ts: number;
|
||||
/**
|
||||
* Whether actual compaction occurred. Always false from the harness
|
||||
* path: SDK auto-compaction runs asynchronously in the background
|
||||
* and the harness does not synchronously force it.
|
||||
*/
|
||||
readonly compacted: false;
|
||||
readonly trigger?: "budget" | "overflow" | "manual";
|
||||
readonly force?: boolean;
|
||||
readonly sdkSessionId?: string;
|
||||
readonly currentTokenCount?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface WrittenOpenClawCompactionMarker {
|
||||
readonly path: string;
|
||||
readonly marker: OpenClawCompactionMarker;
|
||||
}
|
||||
|
||||
function compactJsonValue<T extends Record<string, unknown>>(input: T): T {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an OpenClaw-shaped compaction marker JSON file under
|
||||
* `<workspaceDir>/<subdir>/openclaw-compaction-<sessionId>-<ts>.json`.
|
||||
*
|
||||
* Returns the resolved file path and the marker payload that was
|
||||
* written. Throws if the workspaceDir or sessionId is missing/empty
|
||||
* (the caller should not invoke this without those — the harness
|
||||
* `compact()` must validate first).
|
||||
*/
|
||||
export async function writeOpenClawCompactionMarker(
|
||||
input: OpenClawCompactionMarkerInput,
|
||||
options: OpenClawCompactionMarkerOptions = {},
|
||||
): Promise<WrittenOpenClawCompactionMarker> {
|
||||
if (!input.workspaceDir || typeof input.workspaceDir !== "string") {
|
||||
throw new Error("[copilot:compaction-bridge] workspaceDir is required to write a marker");
|
||||
}
|
||||
if (!input.sessionId || typeof input.sessionId !== "string") {
|
||||
throw new Error("[copilot:compaction-bridge] sessionId is required to write a marker");
|
||||
}
|
||||
|
||||
const now = options.now ?? Date.now;
|
||||
const fs = options.fs ?? { mkdir, writeFile };
|
||||
const subdir = options.subdir ?? "files";
|
||||
const ts = now();
|
||||
const safeSessionId = input.sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
// Filename pattern: ts-first so listings sort chronologically. Suffix
|
||||
// sessionId for collision safety when multiple sessions share a
|
||||
// workspace. Matches the proposal's `openclaw-compaction-<ts>` prefix.
|
||||
const filename = `openclaw-compaction-${ts}-${safeSessionId}.json`;
|
||||
const dirPath = join(input.workspaceDir, subdir);
|
||||
const filePath = join(dirPath, filename);
|
||||
|
||||
const marker: OpenClawCompactionMarker = compactJsonValue({
|
||||
version: 1 as const,
|
||||
source: "copilot-harness" as const,
|
||||
sessionId: input.sessionId,
|
||||
ts,
|
||||
compacted: false as const,
|
||||
trigger: input.trigger,
|
||||
force: input.force,
|
||||
sdkSessionId: input.sdkSessionId,
|
||||
currentTokenCount: input.currentTokenCount,
|
||||
reason: input.reason,
|
||||
});
|
||||
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
|
||||
|
||||
return { path: filePath, marker };
|
||||
}
|
||||
|
||||
@@ -223,6 +223,6 @@ describe("sdk dependency constants", () => {
|
||||
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
|
||||
});
|
||||
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
|
||||
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.4");
|
||||
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.9");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.en
|
||||
|
||||
export const COPILOT_SDK_FALLBACK_DIR = resolveCopilotSdkFallbackDir();
|
||||
|
||||
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.4";
|
||||
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.9";
|
||||
|
||||
let cached: Promise<typeof Sdk> | undefined;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepInfra provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
|
||||
4
extensions/diagnostics-otel/npm-shrinkwrap.json
generated
4
extensions/diagnostics-otel/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/api-logs": "0.218.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,10 +34,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31"
|
||||
"openclawVersion": "2026.6.1-alpha.3"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-prometheus",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/diagnostics-prometheus",
|
||||
"version": "2026.5.31"
|
||||
"version": "2026.6.1-alpha.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-prometheus",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw diagnostics Prometheus exporter for runtime metrics.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31"
|
||||
"openclawVersion": "2026.6.1-alpha.3"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/diffs-language-pack",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/diffs-language-pack",
|
||||
"version": "2026.5.31"
|
||||
"version": "2026.6.1-alpha.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs-language-pack",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw diffs viewer syntax highlighting language pack",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -22,13 +22,13 @@
|
||||
"minHostVersion": ">=2026.5.27"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"assetScripts": {
|
||||
"build": "node ../../scripts/build-diffs-viewer-runtime.mjs full"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31",
|
||||
"openclawVersion": "2026.6.1-alpha.3",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./assets/viewer-runtime.js",
|
||||
|
||||
4
extensions/diffs/npm-shrinkwrap.json
generated
4
extensions/diffs/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.2.4",
|
||||
"@pierre/theme": "1.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw read-only diff viewer plugin and file renderer for agents.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -29,13 +29,13 @@
|
||||
"minHostVersion": ">=2026.4.30"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"assetScripts": {
|
||||
"build": "node ../../scripts/build-diffs-viewer-runtime.mjs curated"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31",
|
||||
"openclawVersion": "2026.6.1-alpha.3",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./assets/viewer-runtime.js",
|
||||
|
||||
6
extensions/discord/npm-shrinkwrap.json
generated
6
extensions/discord/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "0.19.2",
|
||||
"discord-api-types": "0.38.48",
|
||||
@@ -16,7 +16,7 @@
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.31"
|
||||
"openclaw": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,7 +20,7 @@
|
||||
"openclaw": "2026.5.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.31"
|
||||
"openclaw": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -67,10 +67,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.31"
|
||||
"pluginApi": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.31"
|
||||
"openclawVersion": "2026.6.1-alpha.3"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/document-extract-plugin",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw local document extraction plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/duckduckgo-plugin",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw DuckDuckGo plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/elevenlabs-speech",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw ElevenLabs speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw Exa plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fal-provider",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"private": true,
|
||||
"description": "OpenClaw fal provider plugin",
|
||||
"type": "module",
|
||||
|
||||
6
extensions/feishu/npm-shrinkwrap.json
generated
6
extensions/feishu/npm-shrinkwrap.json
generated
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.5.31",
|
||||
"version": "2026.6.1-alpha.3",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "1.66.0",
|
||||
"typebox": "1.1.39",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.31"
|
||||
"openclaw": ">=2026.6.1-alpha.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user