mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-20 05:31:59 +08:00
Compare commits
162 Commits
refactor/h
...
qa-fold-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48bdbad66a | ||
|
|
781e2ada63 | ||
|
|
fa78189458 | ||
|
|
dafcdb901f | ||
|
|
0b6eb3230c | ||
|
|
c1fe62ee83 | ||
|
|
aa60716363 | ||
|
|
62dea06219 | ||
|
|
e4270e7709 | ||
|
|
f237f1da6d | ||
|
|
6cfb025143 | ||
|
|
061a3705db | ||
|
|
9e5ac0cea4 | ||
|
|
aff6e221a7 | ||
|
|
5df5aa1640 | ||
|
|
59a93a817f | ||
|
|
23b8f5d037 | ||
|
|
17e2fbfa86 | ||
|
|
cbff4fa5bc | ||
|
|
330545f3e9 | ||
|
|
2b0a72bb48 | ||
|
|
583829a342 | ||
|
|
7b94ae9944 | ||
|
|
1609365b3e | ||
|
|
d216f7c876 | ||
|
|
d41a3d28a0 | ||
|
|
8aa58c5fb0 | ||
|
|
e7e85f5436 | ||
|
|
458904037f | ||
|
|
1e53ee4fd5 | ||
|
|
6037d1a85c | ||
|
|
2c8d19d73e | ||
|
|
70a48a680d | ||
|
|
0c210e5e52 | ||
|
|
38807ffba4 | ||
|
|
fb06df6cad | ||
|
|
50614c51a8 | ||
|
|
1f244f60ed | ||
|
|
10b8b32380 | ||
|
|
3b65f1d279 | ||
|
|
1c711048f9 | ||
|
|
f69f81af9e | ||
|
|
cdf4268540 | ||
|
|
b4651f3781 | ||
|
|
107c49e936 | ||
|
|
ffd8c6e5d9 | ||
|
|
9fced92710 | ||
|
|
3bcdf20a44 | ||
|
|
80010a864b | ||
|
|
a536a0ddbc | ||
|
|
925d98d8e4 | ||
|
|
a42a1af942 | ||
|
|
b470b1e21a | ||
|
|
6fc0303ec0 | ||
|
|
6ef4684b89 | ||
|
|
2005812dff | ||
|
|
bf872b30cd | ||
|
|
37962aac95 | ||
|
|
a876f8d073 | ||
|
|
0a3e0d081d | ||
|
|
2c3b582c04 | ||
|
|
e0d58d994d | ||
|
|
dc16aedd2e | ||
|
|
b16fd6bee7 | ||
|
|
51ebe87a09 | ||
|
|
78b5618071 | ||
|
|
ed8ab712dc | ||
|
|
8594af21e9 | ||
|
|
2ddebf3897 | ||
|
|
b9dadb9f66 | ||
|
|
f062171c54 | ||
|
|
b677ea6726 | ||
|
|
e74a7d2f14 | ||
|
|
917a0f3052 | ||
|
|
b3dfa0f1b1 | ||
|
|
772158c716 | ||
|
|
940d33cf89 | ||
|
|
698efb23a6 | ||
|
|
d29c3a5d6f | ||
|
|
341ae21d03 | ||
|
|
378c4134f1 | ||
|
|
cd2d837a1f | ||
|
|
29e44f5eba | ||
|
|
ce7f899165 | ||
|
|
4c3b15bae6 | ||
|
|
4723602e7e | ||
|
|
430682e97a | ||
|
|
5c8761976c | ||
|
|
7fafad8c49 | ||
|
|
47545e04c4 | ||
|
|
aea208f0ac | ||
|
|
4d37f42df7 | ||
|
|
56c5630107 | ||
|
|
99e69e16b7 | ||
|
|
f13dc76ba1 | ||
|
|
0de3d47195 | ||
|
|
f7c3775140 | ||
|
|
e2b52f29e4 | ||
|
|
482d6d59ac | ||
|
|
ff35b29a06 | ||
|
|
5a00720de0 | ||
|
|
817dd593bb | ||
|
|
c218255815 | ||
|
|
3bc936b675 | ||
|
|
4799fe7df6 | ||
|
|
f29af26326 | ||
|
|
b0c1010fbf | ||
|
|
f14a2cb9c5 | ||
|
|
27f702d68f | ||
|
|
0781dae620 | ||
|
|
6256ad86c9 | ||
|
|
f7f415f26b | ||
|
|
2983edd5a2 | ||
|
|
4da36da605 | ||
|
|
92d1f04de3 | ||
|
|
611ad1a097 | ||
|
|
6ef4970988 | ||
|
|
8d9eba3f4f | ||
|
|
40dc8fd147 | ||
|
|
2257a21b7e | ||
|
|
d4833e27c7 | ||
|
|
d1bb2d5a12 | ||
|
|
eb7da0a2e5 | ||
|
|
797865c9dc | ||
|
|
7fcbfa6971 | ||
|
|
3091c13713 | ||
|
|
c159063c70 | ||
|
|
dae37a4579 | ||
|
|
2e0dfda462 | ||
|
|
5b3d652c05 | ||
|
|
b39a932112 | ||
|
|
0c76a98f10 | ||
|
|
a8b5f5d551 | ||
|
|
bbe9669926 | ||
|
|
7580c80f37 | ||
|
|
7f38b1a910 | ||
|
|
8aaf937bc0 | ||
|
|
6467c1962a | ||
|
|
0c565f3b0e | ||
|
|
7211d77553 | ||
|
|
dba291ed35 | ||
|
|
32c02e843a | ||
|
|
5e329f4065 | ||
|
|
e6743eb783 | ||
|
|
dbd5689ea1 | ||
|
|
44b0644e88 | ||
|
|
6aa85dfaa1 | ||
|
|
86b24ac2b2 | ||
|
|
d236612cc0 | ||
|
|
c3390f0bc6 | ||
|
|
a6ac8de523 | ||
|
|
ca527aad9d | ||
|
|
3a435eebc0 | ||
|
|
dfc5bd5fcc | ||
|
|
7cc66b5175 | ||
|
|
fcec95ffd7 | ||
|
|
e67f8ba459 | ||
|
|
33fa225f65 | ||
|
|
86a28636fa | ||
|
|
90ba9fc864 | ||
|
|
f5419b5bb0 | ||
|
|
14fd10f8f8 |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -171,6 +171,10 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/zalo/**"
|
||||
- "docs/channels/zalo.md"
|
||||
"channel: zaloclawbot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "docs/channels/zaloclawbot.md"
|
||||
"channel: zalouser":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -625,6 +625,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.discord_scenario || '' }}
|
||||
@@ -722,6 +723,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.whatsapp_scenario || '' }}
|
||||
@@ -816,6 +818,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
|
||||
@@ -12,7 +12,7 @@ report_include:
|
||||
- Sources/**
|
||||
- ShareExtension/**
|
||||
- ActivityWidget/**
|
||||
- WatchExtension/Sources/**
|
||||
- WatchApp/Sources/**
|
||||
build_arguments:
|
||||
- -destination
|
||||
- generic/platform=iOS Simulator
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"signingRepo": "git@github.com:openclaw/apps-signing.git",
|
||||
"signingBranch": "main",
|
||||
"profileType": "appstore",
|
||||
"appGroupId": "group.ai.openclawfoundation.app.shared",
|
||||
"targets": [
|
||||
{
|
||||
"target": "OpenClaw",
|
||||
@@ -11,7 +12,8 @@
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS"]
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawShareExtension",
|
||||
@@ -20,7 +22,8 @@
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_SHARE_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
|
||||
"capabilities": []
|
||||
"capabilities": ["APP_GROUPS"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawActivityWidget",
|
||||
@@ -39,15 +42,6 @@
|
||||
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchExtension",
|
||||
"displayName": "OpenClaw Watch Extension",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"capabilities": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Local contributors can override this by running scripts/ios-configure-signing.sh.
|
||||
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
|
||||
|
||||
@@ -7,13 +7,12 @@ OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -101,6 +101,7 @@ Release-owner secrets:
|
||||
|
||||
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
|
||||
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
|
||||
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
|
||||
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
|
||||
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
|
||||
|
||||
@@ -155,7 +156,8 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
||||
- `ai.openclawfoundation.app.share`
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `ai.openclawfoundation.app.watchkitapp`
|
||||
- `ai.openclawfoundation.app.watchkitapp.extension`
|
||||
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
|
||||
@@ -41,5 +41,7 @@
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
<key>OpenClawAppGroupIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
10
apps/ios/ShareExtension/OpenClawShareExtension.entitlements
Normal file
10
apps/ios/ShareExtension/OpenClawShareExtension.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -184,7 +184,8 @@ final class ShareViewController: UIViewController {
|
||||
clientId: clientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: "OpenClaw Share",
|
||||
includeDeviceIdentity: false)
|
||||
deviceIdentityProfile: .shareExtension,
|
||||
includeDeviceIdentity: true)
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
@@ -10,8 +10,8 @@ OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
|
||||
|
||||
@@ -19,7 +19,6 @@ OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
|
||||
// so later assignments in local files override the defaults above.
|
||||
|
||||
@@ -62,6 +62,7 @@ struct GatewayConnectConfig {
|
||||
lhs.clientId == rhs.clientId &&
|
||||
lhs.clientMode == rhs.clientMode &&
|
||||
lhs.clientDisplayName == rhs.clientDisplayName &&
|
||||
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
|
||||
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
|
||||
lhsScopes == rhsScopes &&
|
||||
lhsCaps == rhsCaps &&
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>OpenClawAppGroupIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>OpenClawPushAPNsEnvironment</key>
|
||||
|
||||
@@ -18,6 +18,7 @@ enum GatewayOnboardingReset {
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
|
||||
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
|
||||
DeviceAuthStore.clearAll(profile: .shareExtension)
|
||||
|
||||
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
|
||||
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)
|
||||
|
||||
@@ -4,5 +4,9 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -109,10 +109,10 @@ Sources/Voice/VoiceWakePreferences.swift
|
||||
ShareExtension/ShareViewController.swift
|
||||
ActivityWidget/OpenClawActivityWidgetBundle.swift
|
||||
ActivityWidget/OpenClawLiveActivity.swift
|
||||
WatchExtension/Sources/OpenClawWatchApp.swift
|
||||
WatchExtension/Sources/WatchConnectivityReceiver.swift
|
||||
WatchExtension/Sources/WatchInboxStore.swift
|
||||
WatchExtension/Sources/WatchInboxView.swift
|
||||
WatchApp/Sources/OpenClawWatchApp.swift
|
||||
WatchApp/Sources/WatchConnectivityReceiver.swift
|
||||
WatchApp/Sources/WatchInboxStore.swift
|
||||
WatchApp/Sources/WatchInboxView.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift
|
||||
|
||||
@@ -3,6 +3,10 @@ import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct ShareToAgentDeepLinkTests {
|
||||
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
|
||||
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
|
||||
}
|
||||
|
||||
@Test func buildMessageIncludesSharedFields() {
|
||||
let payload = SharedContentPayload(
|
||||
title: "Article",
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -20,9 +20,9 @@
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1146,7 +1146,7 @@ private enum WatchNativeTextInput {
|
||||
suggestions: [String],
|
||||
onSubmit: @escaping (String) -> Void)
|
||||
{
|
||||
WKExtension.shared().visibleInterfaceController?.presentTextInputController(
|
||||
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
|
||||
withSuggestions: suggestions,
|
||||
allowedInputMode: .allowEmoji)
|
||||
{ results in
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>WKAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_WATCH_APP_BUNDLE_ID)</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.watchkit</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -293,6 +293,8 @@ def capture_watch_screenshot
|
||||
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
|
||||
FileUtils.rm_rf(derived_data_path)
|
||||
|
||||
# Single-target watch apps only expose generic simulator build destinations in Xcode.
|
||||
# Keep the selected UDID for install/launch/screenshot below.
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
@@ -303,7 +305,7 @@ def capture_watch_screenshot
|
||||
"-configuration",
|
||||
"Debug",
|
||||
"-destination",
|
||||
"platform=watchOS Simulator,id=#{udid}",
|
||||
"generic/platform=watchOS Simulator",
|
||||
"-derivedDataPath",
|
||||
derived_data_path,
|
||||
"build",
|
||||
@@ -311,10 +313,8 @@ def capture_watch_screenshot
|
||||
)
|
||||
|
||||
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
|
||||
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
|
||||
watch_app_identifier = bundle_identifier_for_product(app_path)
|
||||
watch_extension_identifier = bundle_identifier_for_product(extension_path)
|
||||
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
|
||||
screenshot_mode_bundle_identifiers = [watch_app_identifier]
|
||||
|
||||
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
|
||||
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
|
||||
@@ -492,6 +492,9 @@ def produce_services_for_target(target)
|
||||
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
|
||||
services[:push_notification] = "on"
|
||||
end
|
||||
if target.fetch("capabilities").include?("APP_GROUPS")
|
||||
services[:app_group] = "on"
|
||||
end
|
||||
services
|
||||
end
|
||||
|
||||
@@ -567,6 +570,15 @@ def profile_plist_value(profile_path, key_path)
|
||||
end
|
||||
end
|
||||
|
||||
def profile_plist_array_values(profile_path, key_path)
|
||||
raw = profile_plist_value(profile_path, key_path)
|
||||
return [] unless raw
|
||||
|
||||
raw.lines.map(&:strip).reject do |line|
|
||||
line.empty? || line == "Array {" || line == "}"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_match_profile_capabilities!(target)
|
||||
capabilities = target.fetch("capabilities")
|
||||
return if capabilities.empty?
|
||||
@@ -582,6 +594,17 @@ def validate_match_profile_capabilities!(target)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if capabilities.include?("APP_GROUPS")
|
||||
expected_app_groups = target.fetch("appGroups")
|
||||
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
|
||||
missing = expected_app_groups - actual_app_groups
|
||||
unless missing.empty?
|
||||
UI.user_error!(
|
||||
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_app_store_signing!(readonly:)
|
||||
|
||||
@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
```
|
||||
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
|
||||
Shared encrypted signing storage:
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ targets:
|
||||
embed: true
|
||||
- target: OpenClawActivityWidget
|
||||
embed: true
|
||||
# A companion watch application belongs in the standard Watch bundle location.
|
||||
# PlugIns is for extension products and breaks paired watch installation.
|
||||
- target: OpenClawWatchApp
|
||||
- package: OpenClawKit
|
||||
- package: OpenClawKit
|
||||
@@ -88,7 +90,7 @@ targets:
|
||||
exit 1
|
||||
fi
|
||||
swiftformat --lint --config "$SRCROOT/../../config/swiftformat" \
|
||||
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
|
||||
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchApp,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
|
||||
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
|
||||
- name: SwiftLint
|
||||
basedOnDependencyAnalysis: false
|
||||
@@ -140,6 +142,7 @@ targets:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
|
||||
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
@@ -192,6 +195,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_ENTITLEMENTS: ShareExtension/OpenClawShareExtension.entitlements
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
@@ -206,6 +210,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
@@ -251,13 +256,17 @@ targets:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
|
||||
OpenClawWatchApp:
|
||||
type: application.watchapp2
|
||||
type: application
|
||||
platform: watchOS
|
||||
deploymentTarget: "11.0"
|
||||
sources:
|
||||
- path: WatchApp
|
||||
excludes:
|
||||
- Info.plist
|
||||
dependencies:
|
||||
- target: OpenClawWatchExtension
|
||||
- sdk: AppIntents.framework
|
||||
- sdk: WatchConnectivity.framework
|
||||
- sdk: UserNotifications.framework
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
@@ -274,6 +283,8 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SWIFT_VERSION: "6.0"
|
||||
info:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
@@ -281,42 +292,7 @@ targets:
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
OpenClawWatchExtension:
|
||||
type: watchkit2-extension
|
||||
platform: watchOS
|
||||
deploymentTarget: "11.0"
|
||||
sources:
|
||||
- path: WatchExtension/Sources
|
||||
- path: WatchExtension/Assets.xcassets
|
||||
dependencies:
|
||||
- sdk: AppIntents.framework
|
||||
- sdk: WatchConnectivity.framework
|
||||
- sdk: UserNotifications.framework
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
attributes:
|
||||
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
|
||||
info:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
NSExtensionPointIdentifier: com.apple.watchkit
|
||||
WKApplication: true
|
||||
|
||||
OpenClawTests:
|
||||
type: bundle.unit-test
|
||||
|
||||
@@ -21,10 +21,12 @@ private struct DeviceAuthStoreFile: Codable {
|
||||
}
|
||||
|
||||
public enum DeviceAuthStore {
|
||||
private static let fileName = "device-auth.json"
|
||||
|
||||
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||
public static func loadToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
|
||||
{
|
||||
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
|
||||
let role = self.normalizeRole(role)
|
||||
return store.tokens[role]
|
||||
}
|
||||
@@ -33,10 +35,11 @@ public enum DeviceAuthStore {
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String] = []) -> DeviceAuthEntry
|
||||
scopes: [String] = [],
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
|
||||
{
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
var next = self.readStore()
|
||||
var next = self.readStore(profile: profile)
|
||||
if next?.deviceId != deviceId {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
@@ -50,17 +53,25 @@ public enum DeviceAuthStore {
|
||||
}
|
||||
next?.tokens[normalizedRole] = entry
|
||||
if let store = next {
|
||||
self.writeStore(store)
|
||||
self.writeStore(store, profile: profile)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
public static func clearToken(deviceId: String, role: String) {
|
||||
guard var store = readStore(), store.deviceId == deviceId else { return }
|
||||
public static func clearToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
profile: GatewayDeviceIdentityProfile = .primary)
|
||||
{
|
||||
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
guard store.tokens[normalizedRole] != nil else { return }
|
||||
store.tokens.removeValue(forKey: normalizedRole)
|
||||
self.writeStore(store)
|
||||
self.writeStore(store, profile: profile)
|
||||
}
|
||||
|
||||
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
|
||||
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
@@ -74,14 +85,14 @@ public enum DeviceAuthStore {
|
||||
return Array(Set(trimmed)).sorted()
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
.appendingPathComponent(profile.authFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func readStore() -> DeviceAuthStoreFile? {
|
||||
let url = self.fileURL()
|
||||
private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? {
|
||||
let url = self.fileURL(profile: profile)
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
||||
return nil
|
||||
@@ -90,8 +101,8 @@ public enum DeviceAuthStore {
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||
let url = self.fileURL()
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
|
||||
let url = self.fileURL(profile: profile)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public enum GatewayDeviceIdentityProfile: String, Sendable {
|
||||
case primary
|
||||
case shareExtension
|
||||
|
||||
var identityFileName: String {
|
||||
switch self {
|
||||
case .primary:
|
||||
"device.json"
|
||||
case .shareExtension:
|
||||
"share-device.json"
|
||||
}
|
||||
}
|
||||
|
||||
var authFileName: String {
|
||||
switch self {
|
||||
case .primary:
|
||||
"device-auth.json"
|
||||
case .shareExtension:
|
||||
"share-device-auth.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceIdentity: Codable, Sendable {
|
||||
public var deviceId: String
|
||||
public var publicKey: String
|
||||
@@ -19,6 +42,32 @@ enum DeviceIdentityPaths {
|
||||
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
|
||||
|
||||
static func stateDirURL() -> URL {
|
||||
self.stateDirURL(
|
||||
overrideURL: self.stateDirOverrideURL(),
|
||||
legacyStateDirURL: self.legacyStateDirURL(),
|
||||
appGroupStateDirURL: self.appGroupStateDirURL(),
|
||||
temporaryDirectory: FileManager.default.temporaryDirectory)
|
||||
}
|
||||
|
||||
static func stateDirURL(
|
||||
overrideURL: URL?,
|
||||
legacyStateDirURL: URL?,
|
||||
appGroupStateDirURL: URL?,
|
||||
temporaryDirectory: URL) -> URL
|
||||
{
|
||||
if let overrideURL {
|
||||
return overrideURL
|
||||
}
|
||||
if let appGroupStateDirURL {
|
||||
return appGroupStateDirURL
|
||||
}
|
||||
if let legacyStateDirURL {
|
||||
return legacyStateDirURL
|
||||
}
|
||||
return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func stateDirOverrideURL() -> URL? {
|
||||
for key in self.stateDirEnv {
|
||||
if let raw = getenv(key) {
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -27,34 +76,49 @@ enum DeviceIdentityPaths {
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func legacyStateDirURL() -> URL? {
|
||||
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
|
||||
private static func appGroupStateDirURL() -> URL? {
|
||||
guard
|
||||
let containerURL = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeviceIdentityStore {
|
||||
private static let fileName = "device.json"
|
||||
private static let ed25519SPKIPrefix = Data([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
|
||||
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
|
||||
0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
private static let ed25519PKCS8PrivatePrefix = Data([
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
|
||||
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
|
||||
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
])
|
||||
|
||||
public static func loadOrCreate() -> DeviceIdentity {
|
||||
self.loadOrCreate(fileURL: self.fileURL())
|
||||
self.loadOrCreate(profile: .primary)
|
||||
}
|
||||
|
||||
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
|
||||
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
|
||||
}
|
||||
|
||||
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
|
||||
if let data = try? Data(contentsOf: url) {
|
||||
switch self.decodeStoredIdentity(data) {
|
||||
case .identity(let decoded):
|
||||
case let .identity(decoded):
|
||||
return decoded
|
||||
case .recognizedInvalid:
|
||||
return self.generate()
|
||||
@@ -143,7 +207,7 @@ public enum DeviceIdentityStore {
|
||||
let privateKeyData = Data(base64Encoded: identity.privateKey)
|
||||
else { return nil }
|
||||
|
||||
guard publicKeyData.count == 32 && privateKeyData.count == 32,
|
||||
guard publicKeyData.count == 32, privateKeyData.count == 32,
|
||||
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
|
||||
else { return nil }
|
||||
return DeviceIdentity(
|
||||
@@ -211,11 +275,11 @@ public enum DeviceIdentityStore {
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
|
||||
let base = DeviceIdentityPaths.stateDirURL()
|
||||
return base
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
.appendingPathComponent(profile.identityFileName, isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||
public var clientId: String
|
||||
public var clientMode: String
|
||||
public var clientDisplayName: String?
|
||||
public var deviceIdentityProfile: GatewayDeviceIdentityProfile
|
||||
/// When false, the connection omits the signed device identity payload and cannot use
|
||||
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
|
||||
/// role/scoped sessions such as operator UI clients.
|
||||
@@ -122,6 +123,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
clientDisplayName: String?,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
|
||||
includeDeviceIdentity: Bool = true)
|
||||
{
|
||||
self.role = role
|
||||
@@ -133,6 +135,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||
self.clientId = clientId
|
||||
self.clientMode = clientMode
|
||||
self.clientDisplayName = clientDisplayName
|
||||
self.deviceIdentityProfile = deviceIdentityProfile
|
||||
self.includeDeviceIdentity = includeDeviceIdentity
|
||||
}
|
||||
}
|
||||
@@ -436,13 +439,15 @@ public actor GatewayChannelActor {
|
||||
let clientId = options.clientId
|
||||
let clientMode = options.clientMode
|
||||
let role = options.role
|
||||
let deviceIdentityProfile = options.deviceIdentityProfile
|
||||
let requestedScopes = options.scopes
|
||||
let scopesAreExplicit = options.scopesAreExplicit
|
||||
let includeDeviceIdentity = options.includeDeviceIdentity
|
||||
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
|
||||
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
|
||||
let selectedAuth = self.selectConnectAuth(
|
||||
role: role,
|
||||
includeDeviceIdentity: includeDeviceIdentity,
|
||||
deviceIdentityProfile: deviceIdentityProfile,
|
||||
deviceId: identity?.deviceId,
|
||||
requestedScopes: requestedScopes)
|
||||
let scopes = self.resolveConnectScopes(
|
||||
@@ -532,7 +537,11 @@ public actor GatewayChannelActor {
|
||||
try await self.task?.send(.data(data))
|
||||
do {
|
||||
let response = try await self.waitForConnectResponse(reqId: reqId)
|
||||
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||
try await self.handleConnectResponse(
|
||||
response,
|
||||
identity: identity,
|
||||
role: role,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
self.pendingDeviceTokenRetry = false
|
||||
self.deviceTokenRetryBudgetUsed = false
|
||||
} catch {
|
||||
@@ -550,7 +559,10 @@ public actor GatewayChannelActor {
|
||||
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
||||
{
|
||||
// Retry failed with an explicit device-token mismatch; clear stale local token.
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
DeviceAuthStore.clearToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: role,
|
||||
profile: deviceIdentityProfile)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -559,6 +571,7 @@ public actor GatewayChannelActor {
|
||||
private func selectConnectAuth(
|
||||
role: String,
|
||||
includeDeviceIdentity: Bool,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile,
|
||||
deviceId: String?,
|
||||
requestedScopes: [String]) -> SelectedConnectAuth
|
||||
{
|
||||
@@ -568,7 +581,7 @@ public actor GatewayChannelActor {
|
||||
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
let storedEntry =
|
||||
(includeDeviceIdentity && deviceId != nil)
|
||||
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)
|
||||
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
|
||||
: nil
|
||||
let storedToken = storedEntry?.token
|
||||
let storedScopes = storedEntry?.scopes ?? []
|
||||
@@ -756,7 +769,8 @@ public actor GatewayChannelActor {
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String])
|
||||
scopes: [String],
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile)
|
||||
{
|
||||
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
|
||||
return
|
||||
@@ -765,7 +779,8 @@ public actor GatewayChannelActor {
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: filteredScopes)
|
||||
scopes: filteredScopes,
|
||||
profile: deviceIdentityProfile)
|
||||
}
|
||||
|
||||
private func persistIssuedDeviceToken(
|
||||
@@ -773,7 +788,8 @@ public actor GatewayChannelActor {
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String])
|
||||
scopes: [String],
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile)
|
||||
{
|
||||
if authSource == .bootstrapToken {
|
||||
guard self.shouldPersistBootstrapHandoffTokens() else {
|
||||
@@ -783,20 +799,23 @@ public actor GatewayChannelActor {
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: scopes)
|
||||
scopes: scopes,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
return
|
||||
}
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: scopes)
|
||||
scopes: scopes,
|
||||
profile: deviceIdentityProfile)
|
||||
}
|
||||
|
||||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity?,
|
||||
role: String) async throws
|
||||
role: String,
|
||||
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
|
||||
{
|
||||
if res.ok == false {
|
||||
let error = res.error
|
||||
@@ -855,7 +874,8 @@ public actor GatewayChannelActor {
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
scopes: scopes,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
}
|
||||
if self.shouldPersistBootstrapHandoffTokens(),
|
||||
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
|
||||
@@ -873,7 +893,8 @@ public actor GatewayChannelActor {
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
scopes: scopes,
|
||||
deviceIdentityProfile: deviceIdentityProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ public actor GatewayNodeSession {
|
||||
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
|
||||
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
|
||||
let permissions = options.permissions
|
||||
.map { key, value in
|
||||
@@ -179,6 +180,7 @@ public actor GatewayNodeSession {
|
||||
clientId,
|
||||
clientMode,
|
||||
clientDisplayName,
|
||||
deviceIdentityProfile,
|
||||
includeDeviceIdentity,
|
||||
permissions,
|
||||
].joined(separator: "|")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawAppGroup {
|
||||
public static let canonicalIdentifier = "group.ai.openclawfoundation.app.shared"
|
||||
|
||||
public static var identifier: String {
|
||||
let raw = Bundle.main.object(forInfoDictionaryKey: "OpenClawAppGroupIdentifier") as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? self.canonicalIdentifier : trimmed
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
|
||||
}
|
||||
|
||||
public enum ShareGatewayRelaySettings {
|
||||
private static let suiteName = "group.ai.openclaw.shared"
|
||||
private static var suiteName: String { OpenClawAppGroup.identifier }
|
||||
private static let relayConfigKey = "share.gatewayRelay.config.v1"
|
||||
private static let lastEventKey = "share.gatewayRelay.event.v1"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum ShareToAgentSettings {
|
||||
private static let suiteName = "group.ai.openclaw.shared"
|
||||
private static var suiteName: String { OpenClawAppGroup.identifier }
|
||||
private static let defaultInstructionKey = "share.defaultInstruction"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
|
||||
@@ -548,6 +548,7 @@ public struct MessageActionParams: Codable, Sendable {
|
||||
public let action: String
|
||||
public let params: [String: AnyCodable]
|
||||
public let accountid: String?
|
||||
public let requesteraccountid: String?
|
||||
public let requestersenderid: String?
|
||||
public let senderisowner: Bool?
|
||||
public let sessionkey: String?
|
||||
@@ -562,6 +563,7 @@ public struct MessageActionParams: Codable, Sendable {
|
||||
action: String,
|
||||
params: [String: AnyCodable],
|
||||
accountid: String?,
|
||||
requesteraccountid: String? = nil,
|
||||
requestersenderid: String?,
|
||||
senderisowner: Bool?,
|
||||
sessionkey: String?,
|
||||
@@ -575,6 +577,7 @@ public struct MessageActionParams: Codable, Sendable {
|
||||
self.action = action
|
||||
self.params = params
|
||||
self.accountid = accountid
|
||||
self.requesteraccountid = requesteraccountid
|
||||
self.requestersenderid = requestersenderid
|
||||
self.senderisowner = senderisowner
|
||||
self.sessionkey = sessionkey
|
||||
@@ -590,6 +593,7 @@ public struct MessageActionParams: Codable, Sendable {
|
||||
case action
|
||||
case params
|
||||
case accountid = "accountId"
|
||||
case requesteraccountid = "requesterAccountId"
|
||||
case requestersenderid = "requesterSenderId"
|
||||
case senderisowner = "senderIsOwner"
|
||||
case sessionkey = "sessionKey"
|
||||
|
||||
@@ -5,8 +5,99 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DeviceIdentityStoreTests {
|
||||
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
|
||||
func loadsTypeScriptPEMIdentitySchema() throws {
|
||||
@Test
|
||||
func `state directory override wins over shared app group storage`() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let overrideURL = tempDir.appendingPathComponent("override", isDirectory: true)
|
||||
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
|
||||
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
|
||||
|
||||
let selected = DeviceIdentityPaths.stateDirURL(
|
||||
overrideURL: overrideURL,
|
||||
legacyStateDirURL: legacyURL,
|
||||
appGroupStateDirURL: sharedURL,
|
||||
temporaryDirectory: tempDir)
|
||||
|
||||
#expect(selected == overrideURL)
|
||||
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shared app group storage wins over legacy app support storage`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
|
||||
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
|
||||
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
|
||||
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
|
||||
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
|
||||
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
|
||||
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
|
||||
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let selected = DeviceIdentityPaths.stateDirURL(
|
||||
overrideURL: nil,
|
||||
legacyStateDirURL: legacyURL,
|
||||
appGroupStateDirURL: sharedURL,
|
||||
temporaryDirectory: tempDir)
|
||||
|
||||
#expect(selected == sharedURL)
|
||||
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `share extension profile uses separate identity and auth files`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
||||
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: primaryIdentity.deviceId,
|
||||
role: "node",
|
||||
token: "primary-token")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: shareIdentity.deviceId,
|
||||
role: "node",
|
||||
token: "share-token",
|
||||
profile: .shareExtension)
|
||||
|
||||
let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true)
|
||||
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path))
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path))
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
|
||||
#expect(FileManager.default
|
||||
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
|
||||
#expect(
|
||||
DeviceAuthStore
|
||||
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token ==
|
||||
"share-token")
|
||||
|
||||
DeviceAuthStore.clearAll(profile: .shareExtension)
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
|
||||
#expect(DeviceAuthStore
|
||||
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `loads TypeScript PEM identity schema without rewriting or regenerating`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let identityURL = tempDir
|
||||
@@ -40,8 +131,8 @@ struct DeviceIdentityStoreTests {
|
||||
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
|
||||
}
|
||||
|
||||
@Test("does not overwrite a recognized invalid TypeScript identity schema")
|
||||
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
|
||||
@Test
|
||||
func `does not overwrite a recognized invalid TypeScript identity schema`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let identityURL = tempDir
|
||||
@@ -52,14 +143,14 @@ struct DeviceIdentityStoreTests {
|
||||
at: identityURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let stored = """
|
||||
{
|
||||
"version": 1,
|
||||
"deviceId": "stale-device-id",
|
||||
"publicKeyPem": "not-a-valid-public-key",
|
||||
"privateKeyPem": "not-a-valid-private-key",
|
||||
"createdAtMs": 1700000000000
|
||||
}
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"deviceId": "stale-device-id",
|
||||
"publicKeyPem": "not-a-valid-public-key",
|
||||
"privateKeyPem": "not-a-valid-private-key",
|
||||
"createdAtMs": 1700000000000
|
||||
}
|
||||
"""
|
||||
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
let before = try String(contentsOf: identityURL, encoding: .utf8)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
|
||||
struct GatewayModelsCompatibilityTests {
|
||||
@Test
|
||||
func messageActionParamsKeepsRequesterAccountAdditive() {
|
||||
let params = MessageActionParams(
|
||||
channel: "slack",
|
||||
action: "member-info",
|
||||
params: [:],
|
||||
accountid: "default",
|
||||
requestersenderid: "U123",
|
||||
senderisowner: true,
|
||||
sessionkey: nil,
|
||||
sessionid: nil,
|
||||
toolcontext: nil,
|
||||
idempotencykey: "test"
|
||||
)
|
||||
|
||||
#expect(params.requesteraccountid == nil)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
private extension NSLock {
|
||||
func withLock<T>(_ body: () -> T) -> T {
|
||||
extension NSLock {
|
||||
fileprivate func withLock<T>(_ body: () -> T) -> T {
|
||||
self.lock()
|
||||
defer { self.unlock() }
|
||||
return body()
|
||||
@@ -18,7 +18,9 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
|
||||
self.callbacks = callbacks
|
||||
}
|
||||
|
||||
var state: URLSessionTask.State { .running }
|
||||
var state: URLSessionTask.State {
|
||||
.running
|
||||
}
|
||||
|
||||
func resume() {}
|
||||
|
||||
@@ -53,6 +55,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
private var _state: URLSessionTask.State = .suspended
|
||||
private var connectRequestId: String?
|
||||
private var connectAuth: [String: Any]?
|
||||
private var connectDevice: [String: Any]?
|
||||
private var receivePhase = 0
|
||||
private var pendingReceiveHandler:
|
||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
@@ -73,7 +76,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
_ = (closeCode, reason)
|
||||
self.state = .canceling
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<
|
||||
URLSessionWebSocketTask.Message,
|
||||
Error,
|
||||
>) -> Void)? in
|
||||
defer { self.pendingReceiveHandler = nil }
|
||||
return self.pendingReceiveHandler
|
||||
}
|
||||
@@ -92,10 +98,13 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
obj["method"] as? String == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
|
||||
let params = obj["params"] as? [String: Any]
|
||||
let auth = (params?["auth"] as? [String: Any]) ?? [:]
|
||||
let device = params?["device"] as? [String: Any]
|
||||
self.lock.withLock {
|
||||
self.connectRequestId = id
|
||||
self.connectAuth = auth
|
||||
self.connectDevice = device
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +113,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
self.lock.withLock { self.connectAuth }
|
||||
}
|
||||
|
||||
func latestConnectDevice() -> [String: Any]? {
|
||||
self.lock.withLock { self.connectDevice }
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
}
|
||||
@@ -134,7 +147,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
}
|
||||
|
||||
func emitReceiveFailure() {
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<
|
||||
URLSessionWebSocketTask.Message,
|
||||
Error,
|
||||
>) -> Void)? in
|
||||
self._state = .canceling
|
||||
defer { self.pendingReceiveHandler = nil }
|
||||
return self.pendingReceiveHandler
|
||||
@@ -175,7 +191,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
"policy": [
|
||||
"maxPayload": 1,
|
||||
"maxBufferedBytes": 1,
|
||||
"tickIntervalMs": 30_000,
|
||||
"tickIntervalMs": 30000,
|
||||
],
|
||||
"auth": [:],
|
||||
]
|
||||
@@ -223,20 +239,25 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
|
||||
|
||||
private actor SeqGapProbe {
|
||||
private var saw = false
|
||||
func mark() { self.saw = true }
|
||||
func value() -> Bool { self.saw }
|
||||
func mark() {
|
||||
self.saw = true
|
||||
}
|
||||
|
||||
func value() -> Bool {
|
||||
self.saw
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
|
||||
func `websocket ping ignores duplicate success callbacks`() async throws {
|
||||
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
|
||||
try await WebSocketTaskBox(task: task).sendPing()
|
||||
}
|
||||
|
||||
@Test
|
||||
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
|
||||
func `websocket ping ignores duplicate callbacks after first error`() async throws {
|
||||
let firstError = URLError(.networkConnectionLost)
|
||||
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
|
||||
|
||||
@@ -249,7 +270,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
|
||||
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
@@ -284,7 +305,7 @@ struct GatewayNodeSessionTests {
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "ws://example.invalid")!,
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
@@ -305,7 +326,74 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func passwordTakesPrecedenceOverBootstrapToken() async throws {
|
||||
func `share extension identity profile uses separate node identity and token store`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: primaryIdentity.deviceId,
|
||||
role: "node",
|
||||
token: "primary-node-token")
|
||||
|
||||
let session = FakeGatewayWebSocketSession(helloAuth: [
|
||||
"deviceToken": "share-node-token",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
])
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "OpenClaw Share",
|
||||
deviceIdentityProfile: .shareExtension,
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: "shared-password",
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let shareDevice = try #require(session.latestTask()?.latestConnectDevice())
|
||||
let shareDeviceId = try #require(shareDevice["id"] as? String)
|
||||
#expect(shareDeviceId != primaryIdentity.deviceId)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?
|
||||
.token == "primary-node-token")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil)
|
||||
#expect(
|
||||
DeviceAuthStore
|
||||
.loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==
|
||||
"share-node-token")
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `password takes precedence over bootstrap token`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
@@ -320,7 +408,7 @@ struct GatewayNodeSessionTests {
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "ws://example.invalid")!,
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: "stale-bootstrap-token",
|
||||
password: "shared-password",
|
||||
@@ -341,7 +429,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func changedSessionBoxRebuildsExistingGatewayChannel() async throws {
|
||||
func `changed session box rebuilds existing gateway channel`() async throws {
|
||||
let firstSession = FakeGatewayWebSocketSession()
|
||||
let secondSession = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
@@ -357,7 +445,7 @@ struct GatewayNodeSessionTests {
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
url: #require(URL(string: "wss://example.invalid")),
|
||||
token: "shared-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
@@ -370,7 +458,7 @@ struct GatewayNodeSessionTests {
|
||||
})
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
url: #require(URL(string: "wss://example.invalid")),
|
||||
token: "shared-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
@@ -389,7 +477,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
|
||||
func `bootstrap hello stores additional device tokens`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
@@ -440,7 +528,7 @@ struct GatewayNodeSessionTests {
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
url: #require(URL(string: "wss://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
@@ -468,7 +556,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
|
||||
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
@@ -509,7 +597,7 @@ struct GatewayNodeSessionTests {
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
url: #require(URL(string: "wss://example.invalid")),
|
||||
token: "shared-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
@@ -530,7 +618,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws {
|
||||
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
@@ -574,7 +662,7 @@ struct GatewayNodeSessionTests {
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "ws://example.invalid")!,
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
@@ -593,25 +681,25 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
||||
let normalized = canonicalizeCanvasHostUrl(
|
||||
func `normalize canvas host url preserves explicit secure canvas port`() throws {
|
||||
let normalized = try canonicalizeCanvasHostUrl(
|
||||
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
|
||||
activeURL: URL(string: "wss://gateway.example.com")!)
|
||||
activeURL: #require(URL(string: "wss://gateway.example.com")))
|
||||
|
||||
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
|
||||
let normalized = canonicalizeCanvasHostUrl(
|
||||
func `normalize canvas host url backfills gateway host for loopback canvas`() throws {
|
||||
let normalized = try canonicalizeCanvasHostUrl(
|
||||
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
|
||||
activeURL: URL(string: "wss://gateway.example.com:7443")!)
|
||||
activeURL: #require(URL(string: "wss://gateway.example.com:7443")))
|
||||
|
||||
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
||||
func `invoke with timeout returns underlying response before timeout`() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
@@ -619,8 +707,7 @@ struct GatewayNodeSessionTests {
|
||||
onInvoke: { req in
|
||||
#expect(req.id == "1")
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
@@ -628,7 +715,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsTimeoutError() async {
|
||||
func `invoke with timeout returns timeout error`() async {
|
||||
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
@@ -636,8 +723,7 @@ struct GatewayNodeSessionTests {
|
||||
onInvoke: { _ in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
||||
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
@@ -645,7 +731,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
||||
func `invoke with timeout zero disables timeout`() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
@@ -653,15 +739,14 @@ struct GatewayNodeSessionTests {
|
||||
onInvoke: { req in
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws {
|
||||
func `emits synthetic seq gap after reconnect snapshot`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
@@ -687,7 +772,7 @@ struct GatewayNodeSessionTests {
|
||||
}
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "ws://example.invalid")!,
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
1f7eb3a01ca546dc8712ce95e5a03c8713c1d7b7ff42c87aaa7ddb90235f4657 plugin-sdk-api-baseline.json
|
||||
b9b6f597e4f3afc88f69c1c1fea71b7dbbbcd511890d8328590d45f039321fc1 plugin-sdk-api-baseline.jsonl
|
||||
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
|
||||
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -1194,5 +1194,9 @@
|
||||
{
|
||||
"source": "cohere",
|
||||
"target": "cohere"
|
||||
},
|
||||
{
|
||||
"source": "Zalo ClawBot",
|
||||
"target": "Zalo ClawBot"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -52,6 +52,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing.
|
||||
- [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin).
|
||||
- [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (bundled plugin).
|
||||
- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin).
|
||||
- [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (bundled plugin).
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -1409,10 +1409,14 @@ Same-chat `/approve` also works in Slack channels and DMs that already support c
|
||||
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
|
||||
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
|
||||
- Thread starter and initial thread-history context seeding are filtered by configured sender allowlists when applicable.
|
||||
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
|
||||
- Block actions, shortcuts, and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
|
||||
- block actions: selected values, labels, picker values, and `workflow_*` metadata
|
||||
- global shortcuts: callback and actor metadata, routed to the actor's direct session
|
||||
- message shortcuts: callback, actor, channel, thread, and selected-message context
|
||||
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
|
||||
|
||||
Define global or message shortcuts in your Slack app configuration and use any non-empty callback ID. OpenClaw acknowledges matching shortcut payloads, applies the same DM/channel sender policy as other Slack interactions, and queues the sanitized event for the routed agent session. Trigger IDs and response URLs are redacted from agent context.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Primary reference: [Configuration reference - Slack](/gateway/config-channels#slack).
|
||||
|
||||
95
docs/channels/zaloclawbot.md
Normal file
95
docs/channels/zaloclawbot.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
summary: "Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin"
|
||||
read_when:
|
||||
- You want a personal Zalo assistant bot with QR-code login
|
||||
- You are installing or troubleshooting the openclaw-zaloclawbot channel plugin
|
||||
title: "Zalo ClawBot"
|
||||
---
|
||||
|
||||
OpenClaw connects to Zalo ClawBot through the catalog-listed external
|
||||
`@zalo-platforms/openclaw-zaloclawbot` plugin. Login uses a Zalo Mini App QR
|
||||
code.
|
||||
|
||||
## Compatibility
|
||||
|
||||
| Plugin Version | OpenClaw Version | npm dist-tag | Status |
|
||||
| -------------- | ---------------- | ------------ | ------------- |
|
||||
| 0.1.x | >=2026.4.10 | `latest` | Active / Beta |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **>= 22**
|
||||
- [OpenClaw](https://docs.openclaw.ai/install) must be installed (`openclaw` CLI available).
|
||||
- A Zalo account on a mobile device to scan the login QR code.
|
||||
|
||||
## Install with onboard (recommended)
|
||||
|
||||
Run the OpenClaw onboarding wizard and pick **Zalo ClawBot** from the channel menu:
|
||||
|
||||
```bash
|
||||
openclaw onboard
|
||||
```
|
||||
|
||||
The wizard installs the plugin from the official catalog (integrity-verified), renders the login QR right in the terminal, and finishes the channel once you scan it with the Zalo app. No extra commands are needed.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
To add the channel to an already-onboarded gateway, follow these steps:
|
||||
|
||||
### 1. Install the plugin
|
||||
|
||||
```bash
|
||||
openclaw plugins install "@zalo-platforms/openclaw-zaloclawbot@0.1.4"
|
||||
```
|
||||
|
||||
Use the exact pinned version shown above (it matches the official catalog entry), so OpenClaw verifies the package against the catalog integrity hash during install.
|
||||
|
||||
### 2. Enable the plugin in config
|
||||
|
||||
```bash
|
||||
openclaw config set plugins.entries.openclaw-zaloclawbot.enabled true
|
||||
```
|
||||
|
||||
### 3. Generate QR code and log in
|
||||
|
||||
```bash
|
||||
openclaw channels login --channel openclaw-zaloclawbot
|
||||
```
|
||||
|
||||
Scan the terminal-rendered QR code using the Zalo mobile app, accept the Terms of Use inside the Zalo Mini App, and authorize the session.
|
||||
|
||||
### 4. Restart the gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
Unlike the standard developer Zalo channel which requires you to register your own Zalo Official Account (OA) and paste static developer credentials, Zalo ClawBot operates as an **owner-bound personal assistant** using a shared, official infrastructure:
|
||||
|
||||
1. **Secure Onboarding:** The QR code resolves to a secure Zalo Mini App that binds a newly-provisioned, private bot under a shared official OA directly to your Zalo User ID.
|
||||
2. **Owner-Bound Privacy:** By design, the bot is restricted to communicating _only_ with its owner. Messages from other users are dropped at the platform level, making the connection private and secure.
|
||||
3. **Official API path:** The plugin uses Zalo Bot Platform APIs instead of
|
||||
browser or web-session automation.
|
||||
|
||||
## Under the Hood
|
||||
|
||||
The Zalo ClawBot plugin communicates with Zalo APIs via a persistent long-polling message loop. To maintain a clean and lightweight runtime:
|
||||
|
||||
- Long-poll connections utilize the `getUpdates` endpoint.
|
||||
- Webhooks are disabled by default for local desktop/terminal gateway runs.
|
||||
- Messages are processed client-side and mapped directly to your local agent runtime.
|
||||
|
||||
The external plugin manages bot credentials under the OpenClaw state directory.
|
||||
Treat that directory as sensitive and include it in the same access-control and
|
||||
backup policy as the rest of your OpenClaw state.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **QR Login Timeout:** The login token (`zbsk`) expires after 5 minutes for security reasons. If the QR code expires before you scan it, simply rerun the login command to generate a new one.
|
||||
- **Gateway Fails to Load:** Ensure your OpenClaw host version is `2026.4.10` or higher. Older versions do not support the external npm-plugin installation ledger.
|
||||
@@ -315,7 +315,7 @@ Current existing-session limits:
|
||||
- `hover`, `scrollintoview`, `drag`, `select`, `fill`, and `evaluate` reject
|
||||
per-call timeout overrides
|
||||
- `select` supports one value only
|
||||
- `wait --load networkidle` is not supported
|
||||
- `wait --load networkidle` is not supported on existing-session profiles (works on managed and raw/remote CDP)
|
||||
- file uploads require `--ref` / `--input-ref`, do not support CSS
|
||||
`--element`, and currently support one file at a time
|
||||
- dialog hooks do not support `--timeout`
|
||||
|
||||
@@ -62,7 +62,7 @@ Configure compaction under `agents.defaults.compaction` in your `openclaw.json`.
|
||||
|
||||
### Using a different model
|
||||
|
||||
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts any `provider/model-id` string:
|
||||
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts a `provider/model-id` string or a bare alias configured under `agents.defaults.models`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -76,6 +76,8 @@ By default, compaction uses the agent's primary model. Set `agents.defaults.comp
|
||||
}
|
||||
```
|
||||
|
||||
Bare configured aliases resolve to their canonical provider and model before compaction starts. If a bare value matches both an alias and a configured literal model ID, the literal model ID wins. An unmatched bare value remains a model ID on the active provider.
|
||||
|
||||
This works with local models too, for example a second Ollama model dedicated to summarization:
|
||||
|
||||
```json
|
||||
|
||||
@@ -316,6 +316,10 @@
|
||||
"source": "/providers/zalo",
|
||||
"destination": "/channels/zalo"
|
||||
},
|
||||
{
|
||||
"source": "/channels/openclaw-zaloclawbot",
|
||||
"destination": "/channels/zaloclawbot"
|
||||
},
|
||||
{
|
||||
"source": "/providers/whatsapp",
|
||||
"destination": "/channels/whatsapp"
|
||||
@@ -1132,6 +1136,7 @@
|
||||
"channels/feishu",
|
||||
"channels/yuanbao",
|
||||
"channels/zalo",
|
||||
"channels/zaloclawbot",
|
||||
"channels/zalouser"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -668,7 +668,7 @@ Periodic heartbeat runs.
|
||||
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
|
||||
- `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary.
|
||||
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
|
||||
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. Skipped when workspace is read-only.
|
||||
|
||||
@@ -169,9 +169,8 @@ The harness reads its config from per-attempt input
|
||||
- `infiniteSessionConfig` — optional override for the SDK
|
||||
`infiniteSessions` block driven by `harness.compact`. Defaults are safe to
|
||||
leave as-is.
|
||||
- `hooksConfig` — optional native Copilot SDK `SessionHooks` compatibility
|
||||
config for tool/MCP, user-prompt, session, and error callbacks.
|
||||
It is separate from OpenClaw's portable lifecycle hooks.
|
||||
- `hooksConfig` — optional bridge config exposing OpenClaw
|
||||
before/after-message-write hooks to the SDK loop.
|
||||
- `permissionPolicy` — optional override for the SDK's
|
||||
`onPermissionRequest` handler used for built-in SDK tool kinds
|
||||
(`shell`, `write`, `read`, `url`, `mcp`, `memory`, `hook`). Defaults
|
||||
@@ -182,14 +181,6 @@ The harness reads its config from per-attempt input
|
||||
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
|
||||
- `enableSessionTelemetry` — optional SDK session telemetry flag.
|
||||
|
||||
OpenClaw plugin hooks do not need Copilot-specific attempt configuration. The
|
||||
harness runs `before_prompt_build` (and the legacy `before_agent_start`
|
||||
compatibility hook), `llm_input`, `llm_output`, and `agent_end` through the
|
||||
standard harness helpers. Successful SDK compactions also run
|
||||
`before_compaction` and `after_compaction`. Bridged OpenClaw tools continue to
|
||||
run `before_tool_call` and report `after_tool_call`; `hooksConfig` remains for
|
||||
native SDK-only callbacks that have no portable equivalent.
|
||||
|
||||
Nothing in the rest of OpenClaw needs to know about these fields. Other
|
||||
plugins, channels, and core code only see the standard
|
||||
`AgentHarnessAttemptParams` / `AgentHarnessAttemptResult` shape.
|
||||
|
||||
@@ -185,17 +185,6 @@ field; OpenClaw does not infer it from assistant prose. The helper intentionally
|
||||
leaves prompt errors, in-flight turns, and intentional silent replies such as
|
||||
`NO_REPLY` unclassified.
|
||||
|
||||
### Agent-end side effects
|
||||
|
||||
Native harnesses must call `runAgentEndSideEffects(...)` from
|
||||
`openclaw/plugin-sdk/agent-harness-runtime` after they finalize an attempt. It
|
||||
dispatches the portable `agent_end` hook and OpenClaw's research capture without
|
||||
delaying interactive replies. Use `awaitAgentEndSideEffects(...)` for local,
|
||||
non-interactive runs where the attempt must not resolve until those side effects
|
||||
finish. Both helpers accept the same `{ event, ctx }` payload as
|
||||
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
|
||||
attempt result.
|
||||
|
||||
### Native Codex harness mode
|
||||
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
|
||||
@@ -504,9 +504,10 @@ Legacy aliases still normalize to the canonical bundled ids:
|
||||
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
|
||||
the consent page may show Grok Build even though OpenClaw does not require
|
||||
the Grok Build app.
|
||||
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
|
||||
normal xAI provider path because it requires a different upstream API
|
||||
surface than the standard OpenClaw xAI transport.
|
||||
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
|
||||
serves these models through the Responses API, but they do not accept the
|
||||
client-side or custom tools used by OpenClaw's shared agent loop. See the
|
||||
[xAI multi-agent limitations](https://docs.x.ai/developers/model-capabilities/text/multi-agent#limitations).
|
||||
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
|
||||
needs a different bidirectional voice session contract than batch STT or
|
||||
streaming transcription.
|
||||
|
||||
@@ -322,6 +322,7 @@ You can wait on more than just time/text:
|
||||
- `openclaw browser wait --url "**/dash"`
|
||||
- Wait for load state:
|
||||
- `openclaw browser wait --load networkidle`
|
||||
- Supported on managed `openclaw` and raw/remote CDP profiles. The `user` and `existing-session` profiles reject `networkidle`; use `--url`, `--text`, a selector, or `--fn` waits there.
|
||||
- Wait for a JS predicate:
|
||||
- `openclaw browser wait --fn "window.ready===true"`
|
||||
- Wait for a selector to become visible:
|
||||
|
||||
@@ -743,7 +743,7 @@ Compared to the managed `openclaw` profile, existing-session drivers are more co
|
||||
|
||||
- **Screenshots** - page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots.
|
||||
- **Actions** - `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
|
||||
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
|
||||
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported on existing-session profiles (it works on managed and raw/remote CDP profiles). Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
|
||||
- **Dialog visibility** - Managed browser action responses include `blockedByDialog` and `browserState.dialogs.pending` when an action opens a modal dialog; snapshots also include pending dialog state. Respond with `browser dialog --accept/--dismiss --dialog-id <id>` while a dialog is pending. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
|
||||
- **Managed-only features** - batch actions, PDF export, download interception, and `responsebody` still require the managed browser path.
|
||||
|
||||
|
||||
@@ -297,8 +297,3 @@ export function renderIsolatedCodexConfig(params: {
|
||||
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Render only the project trust section for a session-local Codex config. */
|
||||
export function renderIsolatedCodexProjectTrustConfig(projectPaths: string[]): string {
|
||||
return renderIsolatedCodexConfig({ projectPaths });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type AriaSnapshotNode,
|
||||
captureScreenshot,
|
||||
createTargetViaCdp,
|
||||
evaluateJavaScript,
|
||||
formatAriaSnapshot,
|
||||
normalizeCdpWsUrl,
|
||||
type RawAXNode,
|
||||
@@ -329,47 +328,6 @@ describe("cdp internal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateJavaScript", () => {
|
||||
it("throws when Runtime.evaluate returns no result", async () => {
|
||||
const server = await startMockWsServer((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
}
|
||||
});
|
||||
wss = server.wss;
|
||||
await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow(
|
||||
/Runtime\.evaluate returned no result/,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces CDP exceptionDetails alongside result", async () => {
|
||||
const server = await startMockWsServer((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id,
|
||||
result: {
|
||||
result: { type: "undefined" },
|
||||
exceptionDetails: { text: "ReferenceError", lineNumber: 1 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
wss = server.wss;
|
||||
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" });
|
||||
expect(res.exceptionDetails?.text).toBe("ReferenceError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAriaSnapshot", () => {
|
||||
it("returns an empty array when the AX tree is empty", () => {
|
||||
expect(formatAriaSnapshot([], 100)).toStrictEqual([]);
|
||||
@@ -939,27 +897,6 @@ describe("cdp internal", () => {
|
||||
expect(snap.nodes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("swallows a failing Runtime.enable in evaluateJavaScript", async () => {
|
||||
// Exercises the `.catch(() => {})` arrow on `Runtime.enable`.
|
||||
const server = await startMockWsServer((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { result: { type: "number", value: 1 } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
wss = server.wss;
|
||||
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" });
|
||||
expect(res.result.value).toBe(1);
|
||||
});
|
||||
|
||||
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
|
||||
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
|
||||
// the fullPage finally block.
|
||||
@@ -1008,5 +945,4 @@ describe("cdp internal", () => {
|
||||
expect(buf.toString("utf8")).toBe("S");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isWebSocketUrl,
|
||||
parseBrowserHttpUrl as parseHttpUrl,
|
||||
} from "./cdp.helpers.js";
|
||||
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
|
||||
import { createTargetViaCdp, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
|
||||
import {
|
||||
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
|
||||
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
|
||||
@@ -412,32 +412,6 @@ describe("cdp", () => {
|
||||
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
|
||||
});
|
||||
|
||||
it("evaluates javascript via CDP", async () => {
|
||||
const wsPort = await startWsServerWithMessages((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
expect(msg.params?.expression).toBe("1+1");
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { result: { type: "number", value: 2 } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const res = await evaluateJavaScript({
|
||||
wsUrl: `ws://127.0.0.1:${wsPort}`,
|
||||
expression: "1+1",
|
||||
});
|
||||
|
||||
expect(res.result.type).toBe("number");
|
||||
expect(res.result.value).toBe(2);
|
||||
});
|
||||
|
||||
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
|
||||
const httpPort = await startVersionHttpServer({});
|
||||
await expect(
|
||||
|
||||
@@ -299,56 +299,6 @@ async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promi
|
||||
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
|
||||
}
|
||||
|
||||
/** Runtime.evaluate remote-object subset used by CDP helpers. */
|
||||
export type CdpRemoteObject = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
value?: unknown;
|
||||
description?: string;
|
||||
unserializableValue?: string;
|
||||
preview?: unknown;
|
||||
};
|
||||
|
||||
/** Exception details surfaced from CDP Runtime.evaluate. */
|
||||
export type CdpExceptionDetails = {
|
||||
text?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
exception?: CdpRemoteObject;
|
||||
stackTrace?: unknown;
|
||||
};
|
||||
|
||||
/** Evaluate JavaScript in a CDP target and return by value when possible. */
|
||||
export async function evaluateJavaScript(opts: {
|
||||
wsUrl: string;
|
||||
expression: string;
|
||||
awaitPromise?: boolean;
|
||||
returnByValue?: boolean;
|
||||
}): Promise<{
|
||||
result: CdpRemoteObject;
|
||||
exceptionDetails?: CdpExceptionDetails;
|
||||
}> {
|
||||
return await withCdpSocket(opts.wsUrl, async (send) => {
|
||||
await send("Runtime.enable").catch(() => {});
|
||||
const evaluated = (await send("Runtime.evaluate", {
|
||||
expression: opts.expression,
|
||||
awaitPromise: Boolean(opts.awaitPromise),
|
||||
returnByValue: opts.returnByValue ?? true,
|
||||
userGesture: true,
|
||||
includeCommandLineAPI: true,
|
||||
})) as {
|
||||
result?: CdpRemoteObject;
|
||||
exceptionDetails?: CdpExceptionDetails;
|
||||
};
|
||||
|
||||
const result = evaluated?.result;
|
||||
if (!result) {
|
||||
throw new Error("CDP Runtime.evaluate returned no result");
|
||||
}
|
||||
return { result, exceptionDetails: evaluated.exceptionDetails };
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalized accessibility tree node returned by ARIA snapshots. */
|
||||
export type AriaSnapshotNode = {
|
||||
ref: string;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clickChromeMcpCoords,
|
||||
clickChromeMcpElement,
|
||||
buildChromeMcpArgs,
|
||||
decodeChromeMcpStderrTail,
|
||||
ensureChromeMcpAvailable,
|
||||
evaluateChromeMcpScript,
|
||||
@@ -212,114 +211,6 @@ describe("chrome MCP page parsing", () => {
|
||||
).resolves.toEqual(Buffer.from("screenshot:jpeg"));
|
||||
});
|
||||
|
||||
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
|
||||
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--userDataDir",
|
||||
"/tmp/brave-profile",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses browserUrl for existing-session cdpUrl without also passing userDataDir", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
userDataDir: "/tmp/brave-profile",
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--browserUrl",
|
||||
"http://127.0.0.1:9222",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses wsEndpoint for direct existing-session websocket cdpUrl", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--wsEndpoint",
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends custom Chrome MCP args and lets explicit endpoint args override auto-connect", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
userDataDir: "/tmp/brave-profile",
|
||||
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222", "--no-usage-statistics"],
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--browserUrl",
|
||||
"http://127.0.0.1:9222",
|
||||
"--no-usage-statistics",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets explicit Chrome MCP usage-statistics args override the default opt-out", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
mcpArgs: ["--usage-statistics"],
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--usage-statistics",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate an explicit Chrome MCP usage-statistics opt-out", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
mcpArgs: ["--no-usage-statistics"],
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--no-usage-statistics",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the npx package prefix for a custom Chrome MCP command", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
mcpCommand: "/usr/local/bin/chrome-devtools-mcp",
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
}),
|
||||
).toEqual([
|
||||
"--browserUrl",
|
||||
"http://127.0.0.1:9222",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("terminates the owned Chrome MCP subprocess tree when closing temporary sessions", async () => {
|
||||
const session = createFakeSession();
|
||||
Object.assign(session, { ownsProcessTree: true });
|
||||
|
||||
@@ -462,11 +462,6 @@ function buildChromeMcpArgsFromOptions(options: NormalizedChromeMcpProfileOption
|
||||
];
|
||||
}
|
||||
|
||||
/** Build command-line args for launching chrome-devtools-mcp. */
|
||||
export function buildChromeMcpArgs(input?: string | ChromeMcpProfileOptions): string[] {
|
||||
return buildChromeMcpArgsFromOptions(normalizeChromeMcpOptions(input));
|
||||
}
|
||||
|
||||
function drainStderr(transport: StdioClientTransport): () => string {
|
||||
const stream = transport.stderr;
|
||||
if (!stream) {
|
||||
|
||||
@@ -2,6 +2,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loginChutes } from "./oauth.js";
|
||||
|
||||
function boundedErrorResponse(body: string, status = 500): {
|
||||
response: Response;
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
releaseLock: ReturnType<typeof vi.fn>;
|
||||
text: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const encoded = new TextEncoder().encode(body);
|
||||
let read = false;
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
const releaseLock = vi.fn();
|
||||
const text = vi.fn(async () => {
|
||||
throw new Error("response.text() should not be called");
|
||||
});
|
||||
const response = {
|
||||
ok: false,
|
||||
status,
|
||||
headers: new Headers(),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (read) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
read = true;
|
||||
return { done: false, value: encoded };
|
||||
},
|
||||
cancel,
|
||||
releaseLock,
|
||||
}),
|
||||
},
|
||||
text,
|
||||
} as unknown as Response;
|
||||
|
||||
return { response, cancel, releaseLock, text };
|
||||
}
|
||||
|
||||
describe("chutes plugin OAuth", () => {
|
||||
it("rejects unsafe token lifetimes before storing credentials", async () => {
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
@@ -33,4 +69,47 @@ describe("chutes plugin OAuth", () => {
|
||||
}),
|
||||
).rejects.toThrow("Chutes token exchange returned invalid expires_in");
|
||||
});
|
||||
|
||||
it("bounds token exchange error bodies without requiring response.text()", async () => {
|
||||
const errorResponse = boundedErrorResponse(
|
||||
`${"chutes token unavailable ".repeat(1024)}tail-marker`,
|
||||
502,
|
||||
);
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === "https://api.chutes.ai/idp/token") {
|
||||
return errorResponse.response;
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await loginChutes({
|
||||
app: {
|
||||
clientId: "cid_test",
|
||||
redirectUri: "http://127.0.0.1:1456/oauth-callback",
|
||||
scopes: ["openid"],
|
||||
},
|
||||
manual: true,
|
||||
createState: () => "state_test",
|
||||
onAuth: vi.fn(async () => {}),
|
||||
onPrompt: vi.fn(
|
||||
async () => "http://127.0.0.1:1456/oauth-callback?code=code_test&state=state_test",
|
||||
),
|
||||
fetchFn,
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const message = (error as Error).message;
|
||||
expect(message).toContain("Chutes token exchange failed: chutes token unavailable");
|
||||
expect(message).not.toContain("tail-marker");
|
||||
expect(errorResponse.text).not.toHaveBeenCalled();
|
||||
expect(errorResponse.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(errorResponse.releaseLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
parseOAuthCallbackInput,
|
||||
waitForLocalOAuthCallback,
|
||||
} from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const CHUTES_AUTHORIZE_ENDPOINT = "https://api.chutes.ai/idp/authorize";
|
||||
const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token";
|
||||
const CHUTES_USERINFO_ENDPOINT = "https://api.chutes.ai/idp/userinfo";
|
||||
const CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
type OAuthPrompt = {
|
||||
message: string;
|
||||
@@ -152,7 +154,11 @@ async function exchangeChutesCodeForTokens(params: {
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chutes token exchange failed: ${await response.text()}`);
|
||||
const detail = await readResponseTextLimited(
|
||||
response,
|
||||
CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES,
|
||||
).catch(() => "");
|
||||
throw new Error(`Chutes token exchange failed: ${detail}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
|
||||
57
extensions/clickclack/src/http-client.test.ts
Normal file
57
extensions/clickclack/src/http-client.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createClickClackClient } from "./http-client.js";
|
||||
|
||||
function streamedErrorResponse(body: string, limit: number) {
|
||||
const encoded = new TextEncoder().encode(body);
|
||||
let readCount = 0;
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
const releaseLock = vi.fn();
|
||||
const text = vi.fn(async () => {
|
||||
throw new Error("raw response.text() should not be used");
|
||||
});
|
||||
|
||||
const response = {
|
||||
ok: false,
|
||||
status: 502,
|
||||
text,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (readCount > 0) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
readCount += 1;
|
||||
return { done: false, value: encoded };
|
||||
},
|
||||
cancel,
|
||||
releaseLock,
|
||||
}),
|
||||
},
|
||||
} as unknown as Response;
|
||||
|
||||
return {
|
||||
response,
|
||||
cancel,
|
||||
releaseLock,
|
||||
text,
|
||||
expectedDetail: body.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClickClack HTTP client", () => {
|
||||
it("bounds error response bodies without using raw response.text()", async () => {
|
||||
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
|
||||
const fetchMock = vi.fn(async () => streamed.response);
|
||||
const client = createClickClackClient({
|
||||
baseUrl: "https://clickclack.example",
|
||||
token: "test-token",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
await expect(client.me()).rejects.toThrow(`ClickClack 502: ${streamed.expectedDetail}`);
|
||||
|
||||
expect(streamed.text).not.toHaveBeenCalled();
|
||||
expect(streamed.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(streamed.releaseLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
|
||||
* delivery code.
|
||||
*/
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { WebSocket } from "ws";
|
||||
import type {
|
||||
ClickClackChannel,
|
||||
@@ -17,6 +18,8 @@ type ClientOptions = {
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
|
||||
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Creates a typed client for the ClickClack API using bearer-token auth.
|
||||
*/
|
||||
@@ -38,7 +41,8 @@ export function createClickClackClient(options: ClientOptions) {
|
||||
}
|
||||
const response = await fetcher(`${baseUrl}${path}`, { ...init, headers: requestHeaders });
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClickClack ${response.status}: ${await response.text()}`);
|
||||
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
|
||||
throw new Error(`ClickClack ${response.status}: ${detail}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import {
|
||||
GPT5_BEHAVIOR_CONTRACT,
|
||||
GPT5_HEARTBEAT_PROMPT_OVERLAY,
|
||||
renderGpt5PromptOverlay,
|
||||
resolveGpt5SystemPromptContribution,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
@@ -19,10 +18,3 @@ export function resolveCodexSystemPromptContribution(
|
||||
) {
|
||||
return resolveGpt5SystemPromptContribution(params);
|
||||
}
|
||||
|
||||
/** Renders the Codex prompt overlay text for supported GPT-5-family models. */
|
||||
export function renderCodexPromptOverlay(
|
||||
params: Parameters<typeof renderGpt5PromptOverlay>[0],
|
||||
): string | undefined {
|
||||
return renderGpt5PromptOverlay(params);
|
||||
}
|
||||
|
||||
@@ -854,11 +854,6 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
|
||||
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
|
||||
}
|
||||
|
||||
/** Returns whether the current dynamic tool list can serve workspace memory. */
|
||||
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
|
||||
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
|
||||
}
|
||||
|
||||
/** Lists available memory tool names understood by Codex workspace memory routing. */
|
||||
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
|
||||
const availableToolNames = new Set(
|
||||
|
||||
@@ -29,26 +29,6 @@ const loadSharedClientModule = async () => {
|
||||
return await sharedClientModulePromise;
|
||||
};
|
||||
|
||||
/** Returns the process-shared app-server client for normal attempt reuse. */
|
||||
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Returns a leased shared client so startup can release ownership explicitly. */
|
||||
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
|
||||
@@ -2119,7 +2119,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
prependSystemContext: "pre system",
|
||||
appendSystemContext: "post system",
|
||||
prependContext: "queued context",
|
||||
appendContext: "tail context",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
@@ -2159,7 +2158,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
| { input?: Array<{ text?: string; text_elements?: unknown[]; type?: string }> }
|
||||
| undefined;
|
||||
expect(turnStartParams?.input).toEqual([
|
||||
{ type: "text", text: "queued context\n\nhello\n\ntail context", text_elements: [] },
|
||||
{ type: "text", text: "queued context\n\nhello", text_elements: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
resolveSandboxContext,
|
||||
resolveSessionAgentIds,
|
||||
resolveUserPath,
|
||||
awaitAgentEndSideEffects,
|
||||
runAgentEndSideEffects,
|
||||
awaitAgentHarnessAgentEndHook,
|
||||
runAgentHarnessAgentEndHook,
|
||||
runAgentHarnessLlmInputHook,
|
||||
runAgentHarnessLlmOutputHook,
|
||||
runHarnessContextEngineMaintenance,
|
||||
@@ -368,7 +368,7 @@ function formatUnsupportedCodexDynamicToolOutput(type: unknown): string {
|
||||
return `[Unsupported Codex dynamic tool output: ${label}${suffix}]`;
|
||||
}
|
||||
|
||||
type CodexAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
|
||||
type CodexAgentEndHookParams = Parameters<typeof runAgentHarnessAgentEndHook>[0];
|
||||
|
||||
function shouldAwaitCodexAgentEndHook(params: EmbeddedRunAttemptParams): boolean {
|
||||
return !params.messageChannel && !params.messageProvider;
|
||||
@@ -379,10 +379,10 @@ async function runCodexAgentEndHook(
|
||||
hookParams: CodexAgentEndHookParams,
|
||||
): Promise<void> {
|
||||
if (shouldAwaitCodexAgentEndHook(params)) {
|
||||
await awaitAgentEndSideEffects(hookParams);
|
||||
await awaitAgentHarnessAgentEndHook(hookParams);
|
||||
return;
|
||||
}
|
||||
runAgentEndSideEffects(hookParams);
|
||||
runAgentHarnessAgentEndHook(hookParams);
|
||||
}
|
||||
|
||||
export async function runCodexAppServerAttempt(
|
||||
@@ -1020,9 +1020,6 @@ export async function runCodexAppServerAttempt(
|
||||
developerInstructions,
|
||||
messages: codexModelInputHistoryMessages,
|
||||
ctx: hookContext,
|
||||
...("beforeAgentStartResult" in params
|
||||
? { beforeAgentStartResult: params.beforeAgentStartResult }
|
||||
: {}),
|
||||
});
|
||||
const resolveShiftedPromptContextRange = (
|
||||
prompt: string,
|
||||
|
||||
@@ -80,10 +80,6 @@ class CodexThreadStartRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function isCodexThreadStartRequestError(error: unknown): boolean {
|
||||
return error instanceof CodexThreadStartRequestError;
|
||||
}
|
||||
|
||||
export type CodexThreadFinalConfigPatchDecision =
|
||||
| { action: "resume"; binding: CodexAppServerThreadBinding }
|
||||
| { action: "start" };
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// Copilot tests cover harness plugin behavior.
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CopilotClientPool } from "./harness.js";
|
||||
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
|
||||
|
||||
@@ -100,10 +95,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
});
|
||||
|
||||
it("returns the copilot id and default label", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
@@ -528,54 +519,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts deferred compaction cleanup before disposal", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const abort = vi.fn(() => cleanup.resolve("aborted"));
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-pending-cleanup",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-pending-cleanup",
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-pending-cleanup" });
|
||||
await harness.dispose?.();
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts deferred compaction cleanup when the OpenClaw session resets", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const abort = vi.fn(() => cleanup.resolve("aborted"));
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-reset-cleanup",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-reset-cleanup",
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-reset-cleanup" });
|
||||
await harness.reset?.({ sessionId: "oc-reset-cleanup" });
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("session reuse across turns (dogfood finding #4)", () => {
|
||||
// These tests pin the harness's session-reuse contract: subsequent
|
||||
// `runAttempt` calls within the same OpenClaw session should pass
|
||||
@@ -623,83 +566,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(secondCallParams.initialReplayState?.replayInvalid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks reuse while timed-out compaction is pending, then resumes after completion", async () => {
|
||||
const pool = makePoolMock();
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-compacting",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-compacting",
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
|
||||
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
|
||||
cleanup.resolve("completed");
|
||||
await flushAsyncWork();
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t3" }));
|
||||
const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-compacting");
|
||||
expect(sessionStore.store.delete).not.toHaveBeenCalledWith("oc-sess-reuse");
|
||||
});
|
||||
|
||||
it("invalidates the retained SDK binding when deferred compaction is cancelled", async () => {
|
||||
const pool = makePoolMock();
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
let attempt = 0;
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-cancelled",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-cancelled",
|
||||
});
|
||||
}
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
|
||||
cleanup.resolve("aborted");
|
||||
await flushAsyncWork();
|
||||
|
||||
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse");
|
||||
});
|
||||
|
||||
it("does not seed sdkSessionId on the first turn (nothing tracked yet)", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
@@ -1282,7 +1148,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
copilotHome: "/copilot-home",
|
||||
auth: { useLoggedInUser: true },
|
||||
sessionId: "oc-sess-compact",
|
||||
sessionFile: "/session.json",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1313,47 +1178,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resume a session while deferred background compaction is pending", async () => {
|
||||
const cleanup = createDeferred<"aborted" | "completed" | "deadline">();
|
||||
const pool = makePoolMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-background",
|
||||
pooledClient: { key: {} as any, client: {} as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => undefined,
|
||||
cleanup: cleanup.promise,
|
||||
sdkSessionId: "sdk-sess-background",
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(makeCompactParams());
|
||||
const result = await harness.compact?.(makeCompactParams());
|
||||
|
||||
expect(pool.acquire).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "background-compaction-pending",
|
||||
failure: { reason: "background-compaction-pending" },
|
||||
});
|
||||
cleanup.resolve("completed");
|
||||
await flushAsyncWork();
|
||||
});
|
||||
|
||||
it("calls the SDK history compaction RPC without requiring a workspace sidecar", async () => {
|
||||
const beforeCompaction = vi.fn();
|
||||
const afterCompaction = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_compaction", handler: beforeCompaction },
|
||||
{ hookName: "after_compaction", handler: afterCompaction },
|
||||
]),
|
||||
);
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
@@ -1416,19 +1241,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(compact).toHaveBeenCalledWith({ customInstructions: "Keep decisions." });
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
expect(beforeCompaction).toHaveBeenCalledWith(
|
||||
{ messageCount: -1, sessionFile: "/session.json" },
|
||||
expect.objectContaining({
|
||||
modelId: "gpt-4.1",
|
||||
modelProviderId: "github-copilot",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
expect(afterCompaction).toHaveBeenCalledWith(
|
||||
{ compactedCount: 4, messageCount: -1, sessionFile: "/session.json" },
|
||||
expect.objectContaining({ sessionId: "oc-sess-compact-1" }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// Copilot plugin module implements harness behavior.
|
||||
import type { CopilotClient } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
compactWithSafetyTimeout,
|
||||
resolveCompactionTimeoutMs,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
type AgentHarness,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
@@ -94,7 +91,6 @@ type LegacyCopilotSessionBinding = {
|
||||
};
|
||||
|
||||
type CopilotAttemptSessionBinding = Pick<CopilotSessionBinding, "compatKey" | "sdkSessionId">;
|
||||
type DeferredCompactionCleanupOutcome = "aborted" | "completed" | "deadline";
|
||||
|
||||
type CopilotSessionBindingStore = Pick<
|
||||
PluginStateSyncKeyedStore<CopilotSessionBinding>,
|
||||
@@ -403,20 +399,6 @@ function computeSessionCompactKey(params: CopilotSessionCompatParams): string {
|
||||
return computeSessionKey(params, { includeApi: false, includeAuth: false });
|
||||
}
|
||||
|
||||
function buildCopilotCompactionHookContext(params: AgentHarnessCompactParams) {
|
||||
return {
|
||||
...(params.runId ? { runId: params.runId } : {}),
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
modelProviderId: params.provider,
|
||||
modelId: params.model,
|
||||
trigger: params.trigger,
|
||||
...buildAgentHookContextChannelFields(params),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCopilotAgentHarness(
|
||||
options?: CreateCopilotAgentHarnessOptions,
|
||||
): AgentHarness {
|
||||
@@ -425,10 +407,6 @@ export function createCopilotAgentHarness(
|
||||
let disposed = false;
|
||||
let disposePromise: Promise<void> | undefined;
|
||||
const inFlight = new Set<Promise<unknown>>();
|
||||
const deferredCompactionCleanups = new Map<
|
||||
string,
|
||||
Map<Promise<DeferredCompactionCleanupOutcome>, () => void>
|
||||
>();
|
||||
// Maps OpenClaw session id (from AgentHarnessAttemptParams.sessionId) to
|
||||
// the SDK session id + client that owns it. Populated by
|
||||
// runCopilotAttempt via the onSessionEstablished callback so that
|
||||
@@ -450,48 +428,6 @@ export function createCopilotAgentHarness(
|
||||
return poolPromise;
|
||||
}
|
||||
|
||||
function trackDeferredCompactionCleanup(params: {
|
||||
abort: () => void;
|
||||
cleanup: Promise<DeferredCompactionCleanupOutcome>;
|
||||
sessionId: string;
|
||||
}): void {
|
||||
const cleanups =
|
||||
deferredCompactionCleanups.get(params.sessionId) ??
|
||||
new Map<Promise<DeferredCompactionCleanupOutcome>, () => void>();
|
||||
cleanups.set(params.cleanup, params.abort);
|
||||
deferredCompactionCleanups.set(params.sessionId, cleanups);
|
||||
void params.cleanup.then(
|
||||
() => removeDeferredCompactionCleanup(params.sessionId, params.cleanup),
|
||||
() => removeDeferredCompactionCleanup(params.sessionId, params.cleanup),
|
||||
);
|
||||
}
|
||||
|
||||
function removeDeferredCompactionCleanup(
|
||||
sessionId: string,
|
||||
cleanup: Promise<DeferredCompactionCleanupOutcome>,
|
||||
): void {
|
||||
const cleanups = deferredCompactionCleanups.get(sessionId);
|
||||
if (!cleanups) {
|
||||
return;
|
||||
}
|
||||
cleanups.delete(cleanup);
|
||||
if (cleanups.size === 0) {
|
||||
deferredCompactionCleanups.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async function abortDeferredCompactionCleanups(sessionId: string): Promise<void> {
|
||||
const cleanups = deferredCompactionCleanups.get(sessionId);
|
||||
if (!cleanups) {
|
||||
return;
|
||||
}
|
||||
const pending = [...cleanups.entries()];
|
||||
for (const [, abort] of pending) {
|
||||
abort();
|
||||
}
|
||||
await Promise.allSettled(pending.map(([cleanup]) => cleanup));
|
||||
}
|
||||
|
||||
return {
|
||||
id: options?.id ?? "copilot",
|
||||
label: options?.label ?? "GitHub Copilot agent runtime",
|
||||
@@ -552,14 +488,9 @@ export function createCopilotAgentHarness(
|
||||
// surfaces as a prompt error.
|
||||
const currentCompatKey = computeSessionCompatKey(params);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const compactionCleanupPending =
|
||||
openclawSessionId !== undefined && deferredCompactionCleanups.has(openclawSessionId);
|
||||
const tracked =
|
||||
openclawSessionId && !compactionCleanupPending
|
||||
? trackedSessions.get(openclawSessionId)
|
||||
: undefined;
|
||||
const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined;
|
||||
const stored = openclawSessionId
|
||||
? compactionCleanupPending || resetBlockedStoredSessions.has(openclawSessionId)
|
||||
? resetBlockedStoredSessions.has(openclawSessionId)
|
||||
? undefined
|
||||
: lookupStoredBinding(options?.sessionStore, openclawSessionId)
|
||||
: undefined;
|
||||
@@ -614,58 +545,6 @@ export function createCopilotAgentHarness(
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
onDeferredCompaction: openclawSessionId
|
||||
? ({
|
||||
abort,
|
||||
cleanup,
|
||||
sdkSessionId,
|
||||
}: {
|
||||
abort: () => void;
|
||||
cleanup: Promise<DeferredCompactionCleanupOutcome>;
|
||||
sdkSessionId: string;
|
||||
}) => {
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
const stored = lookupStoredBinding(options?.sessionStore, openclawSessionId);
|
||||
const ownsTrackedSession = tracked?.sdkSessionId === sdkSessionId;
|
||||
const ownsStoredSession = stored?.sdkSessionId === sdkSessionId;
|
||||
trackDeferredCompactionCleanup({
|
||||
abort,
|
||||
cleanup,
|
||||
sessionId: openclawSessionId,
|
||||
});
|
||||
if (!ownsTrackedSession && !ownsStoredSession) {
|
||||
return;
|
||||
}
|
||||
// The attempt retains this SDK session until its background
|
||||
// compaction resolves. Preserve its binding for a successful
|
||||
// completion, but do not let a new turn resume it yet.
|
||||
resetBlockedStoredSessions.add(openclawSessionId);
|
||||
void cleanup.then((outcome) => {
|
||||
const currentTracked = trackedSessions.get(openclawSessionId);
|
||||
const currentStored = lookupStoredBinding(
|
||||
options?.sessionStore,
|
||||
openclawSessionId,
|
||||
);
|
||||
const stillOwnsTrackedSession = currentTracked?.sdkSessionId === sdkSessionId;
|
||||
const stillOwnsStoredSession = currentStored?.sdkSessionId === sdkSessionId;
|
||||
if (outcome === "completed") {
|
||||
if (stillOwnsTrackedSession || stillOwnsStoredSession) {
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stillOwnsTrackedSession) {
|
||||
trackedSessions.delete(openclawSessionId);
|
||||
}
|
||||
if (stillOwnsStoredSession) {
|
||||
deleteStoredBinding(options?.sessionStore, openclawSessionId);
|
||||
}
|
||||
if (stillOwnsTrackedSession || stillOwnsStoredSession) {
|
||||
resetBlockedStoredSessions.add(openclawSessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
})();
|
||||
inFlight.add(attemptPromise);
|
||||
@@ -681,7 +560,6 @@ export function createCopilotAgentHarness(
|
||||
if (!openclawSessionId) {
|
||||
return;
|
||||
}
|
||||
await abortDeferredCompactionCleanups(openclawSessionId);
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
if (deleteStoredBinding(options?.sessionStore, openclawSessionId)) {
|
||||
resetBlockedStoredSessions.delete(openclawSessionId);
|
||||
@@ -718,14 +596,6 @@ export function createCopilotAgentHarness(
|
||||
reason: "missing-required-params",
|
||||
};
|
||||
}
|
||||
if (deferredCompactionCleanups.has(openclawSessionId)) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "background-compaction-pending",
|
||||
failure: { reason: "background-compaction-pending" },
|
||||
};
|
||||
}
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const { resolvePoolAcquire } = await import("./src/attempt.js");
|
||||
@@ -753,18 +623,11 @@ export function createCopilotAgentHarness(
|
||||
let handle: PooledClient | undefined;
|
||||
let pool: CopilotClientPool | undefined;
|
||||
let activeSdkSession: CopilotHistoryCompactSession | undefined;
|
||||
const hookContext = buildCopilotCompactionHookContext(params);
|
||||
try {
|
||||
throwIfAborted(params.abortSignal);
|
||||
pool = await getPool();
|
||||
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
|
||||
const client = handle.client;
|
||||
// Manual compaction resumes a distinct SDK session, bypassing the attempt event bridge.
|
||||
// Run the portable lifecycle hook here so both compaction paths stay observable.
|
||||
await runAgentHarnessBeforeCompactionHook({
|
||||
sessionFile: params.sessionFile,
|
||||
ctx: hookContext,
|
||||
});
|
||||
compactResult = await compactWithSafetyTimeout(
|
||||
(abortSignal) =>
|
||||
compactTrackedSdkSession({
|
||||
@@ -830,13 +693,6 @@ export function createCopilotAgentHarness(
|
||||
};
|
||||
}
|
||||
const compacted = compactResult.tokensRemoved > 0 || compactResult.messagesRemoved > 0;
|
||||
if (compacted) {
|
||||
await runAgentHarnessAfterCompactionHook({
|
||||
sessionFile: params.sessionFile,
|
||||
compactedCount: compactResult.messagesRemoved,
|
||||
ctx: hookContext,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
compacted,
|
||||
@@ -853,12 +709,6 @@ export function createCopilotAgentHarness(
|
||||
if (inFlight.size > 0) {
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
// Deferred compaction callbacks retain pooled clients after an attempt.
|
||||
// Cancel them before pool disposal so they cannot outlive this harness.
|
||||
const cleanupSessionIds = [...deferredCompactionCleanups.keys()];
|
||||
for (const sessionId of cleanupSessionIds) {
|
||||
await abortDeferredCompactionCleanups(sessionId);
|
||||
}
|
||||
trackedSessions.clear();
|
||||
resetBlockedStoredSessions.clear();
|
||||
if (createdPool) {
|
||||
|
||||
@@ -8,11 +8,6 @@ import type {
|
||||
AgentHarnessAttemptResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCopilotAttempt } from "./attempt.js";
|
||||
import type { CopilotClientPool } from "./runtime.js";
|
||||
@@ -69,11 +64,6 @@ type FakeSession = {
|
||||
id: string;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
rpc: {
|
||||
history: {
|
||||
cancelBackgroundCompaction: ReturnType<typeof vi.fn<() => Promise<{ cancelled: boolean }>>>;
|
||||
};
|
||||
};
|
||||
sendAndWait: ReturnType<typeof vi.fn<SendAndWaitFn>>;
|
||||
sessionId: string;
|
||||
};
|
||||
@@ -163,13 +153,6 @@ function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSessio
|
||||
handlers.push(handler);
|
||||
listeners.set(eventType, handlers);
|
||||
}),
|
||||
rpc: {
|
||||
history: {
|
||||
cancelBackgroundCompaction: vi.fn<() => Promise<{ cancelled: boolean }>>(async () => ({
|
||||
cancelled: true,
|
||||
})),
|
||||
},
|
||||
},
|
||||
sendAndWait: vi.fn<SendAndWaitFn>(async () => makeAssistantMessageEvent()),
|
||||
sessionId: id,
|
||||
};
|
||||
@@ -217,7 +200,6 @@ function makeFakeSdk(
|
||||
return {
|
||||
client: {
|
||||
createSession,
|
||||
deleteSession: vi.fn(async () => undefined),
|
||||
resumeSession,
|
||||
stop: vi.fn(async () => []),
|
||||
},
|
||||
@@ -267,9 +249,7 @@ function makeParams(
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("runCopilotAttempt", () => {
|
||||
@@ -294,381 +274,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(getSdkSessionId(result)).toBe("sess-1");
|
||||
});
|
||||
|
||||
it("runs generic prompt and lifecycle hooks through the standard harness helpers", async () => {
|
||||
const beforePromptBuild = vi.fn(() => ({
|
||||
prependContext: "Use the current repository state.",
|
||||
appendContext: "Finish with the current test status.",
|
||||
appendSystemContext: "Keep the final response concise.",
|
||||
}));
|
||||
const afterToolCall = vi.fn();
|
||||
const llmInput = vi.fn();
|
||||
const llmOutput = vi.fn();
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_prompt_build", handler: beforePromptBuild },
|
||||
{ hookName: "after_tool_call", handler: afterToolCall },
|
||||
{ hookName: "llm_input", handler: llmInput },
|
||||
{ hookName: "llm_output", handler: llmOutput },
|
||||
{ hookName: "agent_end", handler: agentEnd },
|
||||
]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
|
||||
},
|
||||
});
|
||||
const createToolBridge = vi.fn(
|
||||
async (input: {
|
||||
onToolCompleted?: (completion: {
|
||||
args: Record<string, unknown>;
|
||||
result: unknown;
|
||||
startedAt: number;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
}) => Promise<void>;
|
||||
}) => {
|
||||
await input.onToolCompleted?.({
|
||||
args: { path: "README.md" },
|
||||
result: { content: [{ text: "read result", type: "text" }] },
|
||||
startedAt: Date.now(),
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read",
|
||||
});
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
},
|
||||
);
|
||||
|
||||
await runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(beforePromptBuild).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: "hello" }),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
const cfg = sdk.createSession.mock.calls[0]?.[0] as {
|
||||
systemMessage?: { content?: string };
|
||||
};
|
||||
expect(cfg.systemMessage?.content).toContain("Keep the final response concise.");
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as { prompt?: string };
|
||||
expect(messageOptions.prompt).toBe(
|
||||
"Use the current repository state.\n\nhello\n\nFinish with the current test status.",
|
||||
);
|
||||
expect(llmInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
historyMessages: [],
|
||||
model: "gpt-4o",
|
||||
prompt:
|
||||
"Use the current repository state.\n\nhello\n\nFinish with the current test status.",
|
||||
provider: "github-copilot",
|
||||
runId: "run-1",
|
||||
}),
|
||||
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
|
||||
);
|
||||
expect(llmOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assistantTexts: ["done"],
|
||||
model: "gpt-4o",
|
||||
provider: "github-copilot",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1" }),
|
||||
);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: true }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
expect(afterToolCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { path: "README.md" },
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read",
|
||||
}),
|
||||
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps generic compaction hooks attached through asynchronous SDK completion", async () => {
|
||||
const beforeCompaction = vi.fn();
|
||||
const afterCompaction = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_compaction", handler: beforeCompaction },
|
||||
{ hookName: "after_compaction", handler: afterCompaction },
|
||||
]),
|
||||
);
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.sendAndWait).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
if (!activeSession) {
|
||||
throw new Error("expected Copilot session");
|
||||
}
|
||||
expect(activeSession.disconnect).not.toHaveBeenCalled();
|
||||
activeSession.emit("session.compaction_complete", { messagesRemoved: 4, success: true });
|
||||
|
||||
await attempt;
|
||||
|
||||
expect(beforeCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageCount: -1,
|
||||
sessionFile: "session.json",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
expect(afterCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compactedCount: 4,
|
||||
messageCount: -1,
|
||||
sessionFile: "session.json",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
expect(beforeCompaction.mock.calls[0]?.[0]).not.toHaveProperty("messages");
|
||||
});
|
||||
|
||||
it("does not await background compaction hooks before returning a turn", async () => {
|
||||
const releaseBeforeCompaction = createDeferred<void>();
|
||||
const beforeCompaction = vi.fn(async () => releaseBeforeCompaction.promise);
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_compaction", handler: beforeCompaction }]),
|
||||
);
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
|
||||
expect(result.timedOut).toBe(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(beforeCompaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
releaseBeforeCompaction.resolve();
|
||||
activeSession?.emit("session.compaction_complete", { success: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a successful turn while background compaction remains observed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams(), { pool });
|
||||
const result = await attempt;
|
||||
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeUndefined();
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
expect(sdk.client.deleteSession).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
|
||||
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
expect(pool.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels retained compaction when the caller aborts after a turn result", async () => {
|
||||
const controller = new AbortController();
|
||||
const onDeferredCompaction = vi.fn();
|
||||
let activeSession: FakeSession | undefined;
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
activeSession = session;
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
setTimeout(() => controller.abort(), 0);
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
|
||||
onDeferredCompaction,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
|
||||
const result = await attempt;
|
||||
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(activeSession?.abort).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
expect(onDeferredCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sdkSessionId: "sess-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the native prompt hook's effective input through llm_input", async () => {
|
||||
const llmInput = vi.fn();
|
||||
const onUserPromptSubmitted = vi.fn().mockResolvedValue({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "llm_input", handler: llmInput }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session, cfg) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
const hooks = cfg.hooks as {
|
||||
onUserPromptSubmitted?: (
|
||||
input: { prompt: string },
|
||||
invocation: { sessionId: string },
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
await hooks.onUserPromptSubmitted?.(
|
||||
{ prompt: "hello" },
|
||||
{ sessionId: session.sessionId },
|
||||
);
|
||||
return makeAssistantMessageEvent("done");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await runCopilotAttempt(makeParams({ hooksConfig: { onUserPromptSubmitted } } as never), {
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onUserPromptSubmitted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: "hello" }),
|
||||
{ sessionId: "sess-1" },
|
||||
);
|
||||
expect(llmInput).toHaveBeenCalledTimes(1);
|
||||
expect(llmInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Review the authentication change.\n\nUse the approved repository.",
|
||||
}),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the precomputed legacy before_agent_start result", async () => {
|
||||
const beforeAgentStart = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_agent_start", handler: beforeAgentStart }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
beforeAgentStartResult: { prependContext: "Use the cached result." },
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
expect(beforeAgentStart).not.toHaveBeenCalled();
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as { prompt?: string };
|
||||
expect(messageOptions.prompt).toBe("Use the cached result.\n\nhello");
|
||||
});
|
||||
|
||||
it("preserves native Copilot SDK hooks alongside generic lifecycle hooks", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const onPreToolUse = vi.fn();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
hooksConfig: { onPreToolUse },
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
const cfg = sdk.createSession.mock.calls[0]?.[0] as {
|
||||
hooks?: { onPreToolUse?: unknown };
|
||||
};
|
||||
expect(cfg.hooks?.onPreToolUse).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("does not emit llm_output when cancellation happens before the SDK turn starts", async () => {
|
||||
const llmOutput = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "llm_output", handler: llmOutput }]),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
const result = await runCopilotAttempt(
|
||||
makeParams({ abortSignal: controller.signal } as never),
|
||||
{
|
||||
onSessionEstablished: () => controller.abort(),
|
||||
pool: makeFakePool(sdk),
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(sdk.sessions[0]?.sendAndWait).not.toHaveBeenCalled();
|
||||
expect(llmOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for agent_end hooks before resolving one-shot attempts", async () => {
|
||||
let releaseAgentEnd: () => void = () => undefined;
|
||||
const agentEndSettled = new Promise<void>((resolve) => {
|
||||
releaseAgentEnd = resolve;
|
||||
});
|
||||
const agentEnd = vi.fn(() => agentEndSettled);
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
|
||||
},
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const run = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }).then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(agentEnd).toHaveBeenCalledTimes(1);
|
||||
expect(settled).toBe(false);
|
||||
releaseAgentEnd();
|
||||
await expect(run).resolves.toMatchObject({ promptError: undefined });
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards prompt images as SDK blob attachments", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
@@ -1142,10 +747,6 @@ describe("runCopilotAttempt", () => {
|
||||
it("abort path (signal already aborted)", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
@@ -1157,10 +758,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.externalAbort).toBe(true);
|
||||
expect(sdk.createSession).toHaveBeenCalledTimes(0);
|
||||
expect(pool["acquire"]).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("abort path (signal fires after settled)", async () => {
|
||||
@@ -1323,10 +920,6 @@ describe("runCopilotAttempt", () => {
|
||||
});
|
||||
|
||||
it("tool bridge failures become prompt errors", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => {
|
||||
@@ -1342,20 +935,9 @@ describe("runCopilotAttempt", () => {
|
||||
expect(sdk.createSession).toHaveBeenCalledTimes(0);
|
||||
expect(pool["acquire"]).toHaveBeenCalledTimes(0);
|
||||
expect(pool["release"]).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "[copilot-attempt] tool-bridge construction failed: bridge failed",
|
||||
success: false,
|
||||
}),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsupported providers skip injected tool bridge wiring", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
@@ -1370,30 +952,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(getPromptErrorCode(result)).toBe("model_not_supported");
|
||||
expect(createToolBridge).toHaveBeenCalledTimes(0);
|
||||
expect(sdk.createSession).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ modelId: "claude", modelProviderId: "anthropic" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports pool-release failures through agent_end before rejecting", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
pool.release.mockRejectedValueOnce(new Error("release failed"));
|
||||
|
||||
await expect(runCopilotAttempt(makeParams(), { pool })).rejects.toThrow("release failed");
|
||||
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "release failed",
|
||||
success: false,
|
||||
}),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("default permission policy rejects fail-closed", async () => {
|
||||
@@ -1589,53 +1147,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect("systemMessage" in cfg).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps raw model probes outside generic prompt hooks", async () => {
|
||||
const beforePromptBuild = vi.fn(() => ({
|
||||
appendContext: "must not reach raw model probes",
|
||||
prependSystemContext: "must not reach raw model probes",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
modelRun: true,
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
expect(beforePromptBuild).not.toHaveBeenCalled();
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
prompt?: string;
|
||||
};
|
||||
expect(messageOptions.prompt).toBe("hello");
|
||||
});
|
||||
|
||||
it("keeps promptMode none runs outside generic prompt hooks", async () => {
|
||||
const beforePromptBuild = vi.fn(() => ({
|
||||
appendContext: "must not reach raw model probes",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
promptMode: "none",
|
||||
} as never),
|
||||
{ pool: makeFakePool(sdk) },
|
||||
);
|
||||
|
||||
expect(beforePromptBuild).not.toHaveBeenCalled();
|
||||
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
prompt?: string;
|
||||
};
|
||||
expect(messageOptions.prompt).toBe("hello");
|
||||
});
|
||||
|
||||
it("appends extraSystemPrompt after rendered bootstrap instructions", async () => {
|
||||
const rendered = "# Project Context\n## /ws/SOUL.md\n\nSoul voice goes here.";
|
||||
workspaceBootstrapMock.resolveCopilotWorkspaceBootstrapContext.mockResolvedValueOnce({
|
||||
@@ -1756,10 +1267,6 @@ describe("runCopilotAttempt", () => {
|
||||
});
|
||||
|
||||
it("timeout", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(undefined);
|
||||
@@ -1773,140 +1280,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(getSdkSessionId(result)).toBe("sess-1");
|
||||
expect(sdk.sessions[0]?.abort).toHaveBeenCalledTimes(0);
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "Copilot SDK turn timed out.",
|
||||
success: false,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a timeout during active SDK compaction", async () => {
|
||||
const afterCompaction = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
sdk.sessions[0]?.emit("session.compaction_complete", { messagesRemoved: 3, success: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(sdk.client.deleteSession).not.toHaveBeenCalled();
|
||||
expect(afterCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ compactedCount: 3, sessionFile: "session.json" }),
|
||||
expect.objectContaining({ runId: "run-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
session.emit("session.compaction_complete", { success: true });
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.timedOutDuringCompaction).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds deferred cleanup when SDK compaction never completes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), { pool });
|
||||
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
|
||||
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
expect(pool.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels deferred cleanup when the timed-out caller aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCopilotAttempt(makeParams({ abortSignal: controller.signal }), {
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
expect(sdk.sessions[0]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
controller.abort();
|
||||
await vi.waitFor(() => {
|
||||
expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1");
|
||||
});
|
||||
|
||||
it("keeps the compaction timeout classification after deferred completion", async () => {
|
||||
const mirror = createDeferred<void>();
|
||||
dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear();
|
||||
dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockImplementationOnce(() => mirror.promise);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
session.emit("session.compaction_start", {});
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
|
||||
await vi.waitFor(() => {
|
||||
expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
sdk.sessions[0]?.emit("session.compaction_complete", { success: true });
|
||||
mirror.resolve();
|
||||
|
||||
const result = await attempt;
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.timedOutDuringCompaction).toBe(true);
|
||||
});
|
||||
|
||||
it("G1: SDK timeout rejection (Error 'Timeout after Nms waiting for session.idle') sets timedOut, leaves promptError undefined, and does NOT abort the session", async () => {
|
||||
@@ -2873,10 +2246,6 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
it("fails closed when sandbox is enabled with a cwd override", async () => {
|
||||
const sandbox = makeSandboxStub({ workspaceAccess: "rw" });
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
@@ -2897,17 +2266,9 @@ describe("runCopilotAttempt", () => {
|
||||
expect(getPromptErrorCode(result)).toBe("sandbox_cwd_override_unsupported");
|
||||
expect(createToolBridge).not.toHaveBeenCalled();
|
||||
expect(sdk.createSession).not.toHaveBeenCalled();
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when sandbox resolution fails", async () => {
|
||||
const agentEnd = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "agent_end", handler: agentEnd }]),
|
||||
);
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done"));
|
||||
@@ -2931,10 +2292,6 @@ describe("runCopilotAttempt", () => {
|
||||
);
|
||||
expect(createToolBridge).not.toHaveBeenCalled();
|
||||
expect(sdk.createSession).not.toHaveBeenCalled();
|
||||
expect(agentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when creating the sandbox copy workspace fails", async () => {
|
||||
|
||||
@@ -8,22 +8,12 @@ import type {
|
||||
SandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
detectAndLoadAgentHarnessPromptImages,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveCompactionTimeoutMs,
|
||||
resolveSandboxContext as defaultResolveSandboxContext,
|
||||
resolveSessionAgentIds,
|
||||
resolveUserPath,
|
||||
runAgentHarnessAfterToolCallHook,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
awaitAgentEndSideEffects,
|
||||
runAgentEndSideEffects,
|
||||
runAgentHarnessLlmInputHook,
|
||||
runAgentHarnessLlmOutputHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import {
|
||||
@@ -61,7 +51,6 @@ const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
|
||||
|
||||
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
|
||||
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
|
||||
type CopilotAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
|
||||
export type CopilotSessionConfig = Pick<
|
||||
SessionConfig,
|
||||
| "availableTools"
|
||||
@@ -139,145 +128,6 @@ export interface CopilotAttemptDeps {
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
}) => void;
|
||||
/**
|
||||
* Called before an attempt retains its live SDK session to observe background
|
||||
* compaction. The harness must prevent that session ID from being resumed
|
||||
* until cleanup completes.
|
||||
*/
|
||||
onDeferredCompaction?: (info: {
|
||||
abort: () => void;
|
||||
cleanup: Promise<"aborted" | "completed" | "deadline">;
|
||||
sdkSessionId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
async function runCopilotAgentEndHook(
|
||||
params: AttemptParamsLike,
|
||||
hookParams: CopilotAgentEndHookParams,
|
||||
): Promise<void> {
|
||||
if (!params.messageChannel && !params.messageProvider) {
|
||||
await awaitAgentEndSideEffects(hookParams);
|
||||
return;
|
||||
}
|
||||
runAgentEndSideEffects(hookParams);
|
||||
}
|
||||
|
||||
async function finalizeCopilotAttempt(
|
||||
params: AttemptParamsLike,
|
||||
result: AgentHarnessAttemptResult,
|
||||
ctx: CopilotAgentEndHookParams["ctx"],
|
||||
attemptStartedAt: number,
|
||||
now: () => number,
|
||||
): Promise<AgentHarnessAttemptResult> {
|
||||
await runCopilotAgentEndHook(params, {
|
||||
event: {
|
||||
messages: result.messagesSnapshot,
|
||||
success: !result.aborted && !result.promptError && !result.timedOut,
|
||||
...(result.promptError
|
||||
? { error: result.promptError.message }
|
||||
: result.timedOut
|
||||
? { error: "Copilot SDK turn timed out." }
|
||||
: {}),
|
||||
durationMs: now() - attemptStartedAt,
|
||||
},
|
||||
ctx,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function awaitCompactionCompletionOrAbort(
|
||||
bridge: ReturnType<typeof attachEventBridge>,
|
||||
abortSignal: AbortSignal | undefined,
|
||||
): Promise<"aborted" | "completed"> {
|
||||
if (!abortSignal) {
|
||||
await bridge.awaitCompactionCompletion();
|
||||
return "completed";
|
||||
}
|
||||
if (abortSignal.aborted) {
|
||||
return "aborted";
|
||||
}
|
||||
let resolveAbort: () => void = () => undefined;
|
||||
const aborted = new Promise<"aborted">((resolve) => {
|
||||
resolveAbort = () => resolve("aborted");
|
||||
});
|
||||
abortSignal.addEventListener("abort", resolveAbort, { once: true });
|
||||
try {
|
||||
return await Promise.race([
|
||||
bridge.awaitCompactionCompletion().then(() => "completed" as const),
|
||||
aborted,
|
||||
]);
|
||||
} finally {
|
||||
abortSignal.removeEventListener("abort", resolveAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function deferBackgroundCompactionCleanup(params: {
|
||||
abortSignal: AbortSignal | undefined;
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
sdkSessionId?: string;
|
||||
session: SessionLike;
|
||||
timeoutMs: number;
|
||||
}): Promise<"aborted" | "completed" | "deadline"> {
|
||||
// The SDK can compact after its turn result or a timeout. Keep the bridge
|
||||
// attached so after_compaction uses the originating run context.
|
||||
return (async () => {
|
||||
let outcome: "aborted" | "completed" | "deadline" = "deadline";
|
||||
try {
|
||||
outcome = await awaitCompactionCompletionBeforeDeadline({
|
||||
abortSignal: params.abortSignal,
|
||||
bridge: params.bridge,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (outcome !== "completed") {
|
||||
void params.session.rpc?.history?.cancelBackgroundCompaction?.().catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Event callbacks are best-effort; cleanup still releases the retained session.
|
||||
} finally {
|
||||
params.bridge.detach();
|
||||
try {
|
||||
await params.session.disconnect();
|
||||
} catch {
|
||||
// The attempt has already returned its timeout result.
|
||||
}
|
||||
if (outcome !== "completed" && params.sdkSessionId) {
|
||||
try {
|
||||
await params.handle.client.deleteSession(params.sdkSessionId);
|
||||
} catch {
|
||||
// The timeout path intentionally discards this SDK session either way.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await params.pool.release(params.handle);
|
||||
} catch {
|
||||
// The pool will dispose this client later if its release cannot complete.
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
})();
|
||||
}
|
||||
|
||||
async function awaitCompactionCompletionBeforeDeadline(params: {
|
||||
abortSignal: AbortSignal | undefined;
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
timeoutMs: number;
|
||||
}): Promise<"aborted" | "completed" | "deadline"> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<"deadline">((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve("deadline"), params.timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
awaitCompactionCompletionOrAbort(params.bridge, params.abortSignal),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCopilotAttempt(
|
||||
@@ -285,79 +135,34 @@ export async function runCopilotAttempt(
|
||||
deps: CopilotAttemptDeps,
|
||||
): Promise<AgentHarnessAttemptResult> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const attemptStartedAt = now();
|
||||
const input = params as AttemptParamsLike;
|
||||
const createToolBridge = deps.createToolBridge ?? createCopilotToolBridge;
|
||||
const messages = getMessagesSnapshotInput(input);
|
||||
const modelRef = resolveModelRef(input);
|
||||
const resolvedWorkspaceForSandbox =
|
||||
readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd);
|
||||
const sandboxSessionKey =
|
||||
readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ??
|
||||
readString((input as { sessionKey?: unknown }).sessionKey) ??
|
||||
readString(input.sessionId);
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
config: input.config,
|
||||
agentId: readString(params.agentId),
|
||||
});
|
||||
const hookContextWindowFields = {
|
||||
...(input.contextWindowInfo?.tokens
|
||||
? { contextTokenBudget: input.contextWindowInfo.tokens }
|
||||
: input.contextTokenBudget
|
||||
? { contextTokenBudget: input.contextTokenBudget }
|
||||
: {}),
|
||||
...(input.contextWindowInfo?.source
|
||||
? { contextWindowSource: input.contextWindowInfo.source }
|
||||
: {}),
|
||||
...(input.contextWindowInfo?.referenceTokens
|
||||
? { contextWindowReferenceTokens: input.contextWindowInfo.referenceTokens }
|
||||
: {}),
|
||||
};
|
||||
const hookContext = {
|
||||
runId: input.runId,
|
||||
jobId: input.jobId,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionId: input.sessionId,
|
||||
workspaceDir: resolvedWorkspaceForSandbox,
|
||||
modelProviderId: modelRef.provider,
|
||||
modelId: modelRef.id,
|
||||
trigger: input.trigger,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
...hookContextWindowFields,
|
||||
...buildAgentHookContextChannelFields(input),
|
||||
};
|
||||
const finishAttempt = (result: AgentHarnessAttemptResult) =>
|
||||
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
|
||||
|
||||
if (params.abortSignal?.aborted) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: undefined,
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
return createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: undefined,
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
const modelRef = resolveModelRef(input);
|
||||
if (!SUPPORTED_PROVIDERS.has(modelRef.provider)) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"model_not_supported",
|
||||
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
return createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"model_not_supported",
|
||||
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
let abortRequested = false;
|
||||
@@ -365,7 +170,6 @@ export async function runCopilotAttempt(
|
||||
let externalAbort = false;
|
||||
let settled = false;
|
||||
let sentTurnStarted = false;
|
||||
let timedOutDuringCompaction = false;
|
||||
let timedOut = false;
|
||||
let promptError: Error | undefined;
|
||||
let sdkSessionId: string | undefined;
|
||||
@@ -404,6 +208,12 @@ export async function runCopilotAttempt(
|
||||
// spawned subagents should inherit. When sandbox is disabled (the default),
|
||||
// `resolveSandboxContext` returns `null` and behavior is unchanged from the
|
||||
// pre-fix path.
|
||||
const resolvedWorkspaceForSandbox =
|
||||
readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd);
|
||||
const sandboxSessionKey =
|
||||
readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ??
|
||||
readString((input as { sessionKey?: unknown }).sessionKey) ??
|
||||
readString(input.sessionId);
|
||||
const resolveSandbox = deps.resolveSandboxContextOverride ?? defaultResolveSandboxContext;
|
||||
let sandbox: SandboxContext | null = null;
|
||||
let effectiveWorkspaceDir = resolvedWorkspaceForSandbox;
|
||||
@@ -435,54 +245,52 @@ export async function runCopilotAttempt(
|
||||
settled = true;
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (abortRequested || params.abortSignal?.aborted) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: undefined,
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
return createResult(input, {
|
||||
aborted: true,
|
||||
externalAbort: true,
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"sandbox_resolution_failure",
|
||||
`[copilot-attempt] sandbox resolution failed: ${toError(error).message}`,
|
||||
error,
|
||||
),
|
||||
promptError: undefined,
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
return createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"sandbox_resolution_failure",
|
||||
`[copilot-attempt] sandbox resolution failed: ${toError(error).message}`,
|
||||
error,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
hookContext.workspaceDir = effectiveWorkspaceDir;
|
||||
const requestedCwd = readResolvedAttemptPath(input.cwd);
|
||||
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspaceForSandbox) {
|
||||
settled = true;
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"sandbox_cwd_override_unsupported",
|
||||
"[copilot-attempt] cwd override is not supported for sandboxed Copilot runs; omit cwd or use the agent workspace as cwd",
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
return createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"sandbox_cwd_override_unsupported",
|
||||
"[copilot-attempt] cwd override is not supported for sandboxed Copilot runs; omit cwd or use the agent workspace as cwd",
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
}
|
||||
const effectiveCwd = sandbox?.enabled
|
||||
? effectiveWorkspaceDir
|
||||
: (requestedCwd ?? effectiveWorkspaceDir);
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
config: input.config,
|
||||
agentId: readString(params.agentId),
|
||||
});
|
||||
const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({
|
||||
config: input.config,
|
||||
sessionAgentId,
|
||||
@@ -493,6 +301,7 @@ export async function runCopilotAttempt(
|
||||
resolvedWorkspace: resolvedWorkspaceForSandbox,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const poolAcquire = resolvePoolAcquire(input);
|
||||
|
||||
// Mutable session holder shared with the tool bridge so onYield
|
||||
@@ -531,24 +340,10 @@ export async function runCopilotAttempt(
|
||||
onYieldDetected: () => {
|
||||
yieldDetected = true;
|
||||
},
|
||||
onToolCompleted: ({ args, error, result, startedAt, toolCallId, toolName }) =>
|
||||
runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId,
|
||||
runId: input.runId,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
channelId: hookContext.channelId,
|
||||
startArgs: args,
|
||||
...(result !== undefined ? { result } : {}),
|
||||
...(error ? { error } : {}),
|
||||
startedAt,
|
||||
}),
|
||||
});
|
||||
sdkTools = toolBridge.sdkTools;
|
||||
} catch (error: unknown) {
|
||||
const result = createResult(input, {
|
||||
return createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
@@ -559,7 +354,6 @@ export async function runCopilotAttempt(
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
});
|
||||
return finishAttempt(result);
|
||||
}
|
||||
|
||||
handle = await deps.pool.acquire(poolAcquire.key, poolAcquire.options);
|
||||
@@ -586,60 +380,14 @@ export async function runCopilotAttempt(
|
||||
effectiveWorkspaceDir,
|
||||
warn: (message) => console.warn(message),
|
||||
});
|
||||
const originalDeveloperInstructions =
|
||||
createSystemMessageContent(input, workspaceBootstrap.instructions) ?? "";
|
||||
const promptBuild = isRawCopilotModelRun(input)
|
||||
? {
|
||||
prompt: input.prompt,
|
||||
developerInstructions: originalDeveloperInstructions,
|
||||
}
|
||||
: await resolveAgentHarnessBeforePromptBuildResult({
|
||||
prompt: input.prompt,
|
||||
developerInstructions: originalDeveloperInstructions,
|
||||
messages,
|
||||
ctx: hookContext,
|
||||
...("beforeAgentStartResult" in input
|
||||
? { beforeAgentStartResult: input.beforeAgentStartResult }
|
||||
: {}),
|
||||
});
|
||||
const attemptInput =
|
||||
promptBuild.prompt === input.prompt ? input : { ...input, prompt: promptBuild.prompt };
|
||||
let promptImagesCount = 0;
|
||||
const emitLlmInput = (prompt: string, additionalContext?: string) => {
|
||||
runAgentHarnessLlmInputHook({
|
||||
event: {
|
||||
runId: input.runId,
|
||||
sessionId: input.sessionId,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.id,
|
||||
...(promptBuild.developerInstructions
|
||||
? { systemPrompt: promptBuild.developerInstructions }
|
||||
: {}),
|
||||
prompt: additionalContext ? `${prompt}\n\n${additionalContext}` : prompt,
|
||||
// Copilot SDK sessions own their own transcript. OpenClaw's
|
||||
// mirrored messages are persistence state, not provider input.
|
||||
historyMessages: [],
|
||||
imagesCount: promptImagesCount,
|
||||
tools: sdkTools,
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
};
|
||||
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
|
||||
const sessionConfig = createSessionConfig(
|
||||
attemptInput,
|
||||
input,
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
workspaceBootstrap.instructions,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
hasNativePromptHook
|
||||
? {
|
||||
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
|
||||
emitLlmInput(prompt, additionalContext),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const replayDecision = decideReplayAction({
|
||||
sdkSessionId: input.initialReplayState?.sdkSessionId,
|
||||
@@ -694,46 +442,21 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
bridge = attachEventBridge(session, {
|
||||
onAssistantDelta: input.onAssistantDelta,
|
||||
onCompactionStart: () => {
|
||||
const sessionFile = readString(input.sessionFile);
|
||||
if (!sessionFile) {
|
||||
return;
|
||||
}
|
||||
return runAgentHarnessBeforeCompactionHook({
|
||||
sessionFile,
|
||||
ctx: hookContext,
|
||||
});
|
||||
},
|
||||
onCompactionComplete: ({ messagesRemoved, success }) => {
|
||||
const sessionFile = readString(input.sessionFile);
|
||||
if (!success || !sessionFile) {
|
||||
return;
|
||||
}
|
||||
return runAgentHarnessAfterCompactionHook({
|
||||
sessionFile,
|
||||
compactedCount: messagesRemoved ?? -1,
|
||||
ctx: hookContext,
|
||||
});
|
||||
},
|
||||
getSdkSessionId: () => sdkSessionId,
|
||||
isAborted: () => aborted,
|
||||
});
|
||||
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
const messageOptions = await createMessageOptions(input, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
sandbox,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
});
|
||||
promptImagesCount = messageOptions.attachments?.length ?? 0;
|
||||
if (abortRequested || params.abortSignal?.aborted) {
|
||||
aborted = true;
|
||||
externalAbort = true;
|
||||
} else {
|
||||
sentTurnStarted = true;
|
||||
if (!hasNativePromptHook) {
|
||||
emitLlmInput(attemptInput.prompt);
|
||||
}
|
||||
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
|
||||
await bridge.awaitDeltaChain();
|
||||
if (!bridge.recordSendResult(result) && !aborted) {
|
||||
@@ -741,7 +464,6 @@ export async function runCopilotAttempt(
|
||||
// capability inventory. Do not call session.abort() here: OpenClaw may
|
||||
// resume the in-flight SDK session on the next attempt.
|
||||
timedOut = true;
|
||||
timedOutDuringCompaction = bridge.isCompacting();
|
||||
}
|
||||
const snap = bridge.snapshot();
|
||||
if (!promptError && !timedOut && !aborted && snap.streamError) {
|
||||
@@ -762,7 +484,6 @@ export async function runCopilotAttempt(
|
||||
// in-flight SDK session on the next attempt (the SDK keeps
|
||||
// the server-side session intact across this kind of timeout).
|
||||
timedOut = true;
|
||||
timedOutDuringCompaction = bridge?.isCompacting() === true;
|
||||
// Flush any in-flight delta promise chain so the snapshot
|
||||
// built below in `finally` includes the deltas the SDK already
|
||||
// delivered before the timer fired.
|
||||
@@ -777,75 +498,39 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
} finally {
|
||||
settled = true;
|
||||
if (bridge?.hasObservedCompaction() && session && handle) {
|
||||
const cleanupAbort = new AbortController();
|
||||
const abortCleanup = () => cleanupAbort.abort();
|
||||
if (params.abortSignal?.aborted) {
|
||||
abortCleanup();
|
||||
} else {
|
||||
params.abortSignal?.addEventListener("abort", abortCleanup, { once: true });
|
||||
}
|
||||
const cleanup = deferBackgroundCompactionCleanup({
|
||||
abortSignal: cleanupAbort.signal,
|
||||
bridge,
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
sdkSessionId,
|
||||
session,
|
||||
timeoutMs: resolveCompactionTimeoutMs(input.config),
|
||||
});
|
||||
void cleanup
|
||||
.finally(() => {
|
||||
params.abortSignal?.removeEventListener("abort", abortCleanup);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (sdkSessionId) {
|
||||
try {
|
||||
deps.onDeferredCompaction?.({
|
||||
abort: () => cleanupAbort.abort(),
|
||||
cleanup,
|
||||
sdkSessionId,
|
||||
});
|
||||
} catch {
|
||||
// Session tracking cannot interfere with timeout cleanup.
|
||||
}
|
||||
}
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
} else {
|
||||
await bridge?.awaitCompactionChain();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (session) {
|
||||
try {
|
||||
await session.disconnect();
|
||||
} catch (error: unknown) {
|
||||
disconnectError = toError(error);
|
||||
// A timeout is a higher-fidelity signal than a cleanup-time
|
||||
// disconnect failure; don't let a stale disconnect error
|
||||
// mask the timeout classification the replay-shim depends on.
|
||||
if (!promptError && !timedOut) {
|
||||
promptError = disconnectError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
try {
|
||||
await deps.pool.release(handle);
|
||||
} catch (error: unknown) {
|
||||
const releaseFailure = toError(error);
|
||||
if (promptError) {
|
||||
console.warn(
|
||||
"[copilot-attempt] pool.release failed after primary error",
|
||||
releaseFailure,
|
||||
);
|
||||
} else {
|
||||
releaseError = releaseFailure;
|
||||
}
|
||||
if (session) {
|
||||
try {
|
||||
await session.disconnect();
|
||||
} catch (error: unknown) {
|
||||
disconnectError = toError(error);
|
||||
// A timeout is a higher-fidelity signal than a cleanup-time
|
||||
// disconnect failure; don't let a stale disconnect error
|
||||
// mask the timeout classification the replay-shim depends on.
|
||||
if (!promptError && !timedOut) {
|
||||
promptError = disconnectError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
try {
|
||||
await deps.pool.release(handle);
|
||||
} catch (error: unknown) {
|
||||
const releaseFailure = toError(error);
|
||||
if (promptError) {
|
||||
console.warn("[copilot-attempt] pool.release failed after primary error", releaseFailure);
|
||||
} else {
|
||||
releaseError = releaseFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseError) {
|
||||
throw releaseError;
|
||||
}
|
||||
|
||||
const snap = bridge?.snapshot();
|
||||
@@ -942,7 +627,7 @@ export async function runCopilotAttempt(
|
||||
});
|
||||
}
|
||||
|
||||
const result = createResult(input, {
|
||||
return createResult(input, {
|
||||
aborted,
|
||||
assistantTexts,
|
||||
currentAttemptAssistant: lastAssistant,
|
||||
@@ -961,43 +646,10 @@ export async function runCopilotAttempt(
|
||||
sdkSessionId,
|
||||
sessionIdUsed,
|
||||
timedOut,
|
||||
timedOutDuringCompaction,
|
||||
toolMetas: snap ? [...snap.toolMetas] : [],
|
||||
usage: snap?.usage,
|
||||
yieldDetected,
|
||||
});
|
||||
if (sentTurnStarted) {
|
||||
runAgentHarnessLlmOutputHook({
|
||||
event: {
|
||||
runId: input.runId,
|
||||
sessionId: input.sessionId,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.id,
|
||||
...hookContextWindowFields,
|
||||
resolvedRef:
|
||||
input.runtimePlan?.observability.resolvedRef ?? `${modelRef.provider}/${modelRef.id}`,
|
||||
...(input.runtimePlan?.observability.harnessId
|
||||
? { harnessId: input.runtimePlan.observability.harnessId }
|
||||
: {}),
|
||||
assistantTexts: result.assistantTexts,
|
||||
...(result.lastAssistant ? { lastAssistant: result.lastAssistant } : {}),
|
||||
...(result.attemptUsage ? { usage: result.attemptUsage } : {}),
|
||||
...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}),
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
}
|
||||
if (releaseError) {
|
||||
await finalizeCopilotAttempt(
|
||||
input,
|
||||
{ ...result, promptError: releaseError },
|
||||
hookContext,
|
||||
attemptStartedAt,
|
||||
now,
|
||||
);
|
||||
throw releaseError;
|
||||
}
|
||||
return finishAttempt(result);
|
||||
}
|
||||
|
||||
function createResult(
|
||||
@@ -1017,7 +669,6 @@ function createResult(
|
||||
sdkSessionId?: string;
|
||||
sessionIdUsed?: string;
|
||||
timedOut?: boolean;
|
||||
timedOutDuringCompaction?: boolean;
|
||||
toolMetas?: Array<{ meta?: string; toolName: string }>;
|
||||
usage?: AssistantUsageSnapshot;
|
||||
yieldDetected?: boolean;
|
||||
@@ -1060,7 +711,7 @@ function createResult(
|
||||
sessionFileUsed: readString(params.sessionFile),
|
||||
sessionIdUsed: state.sessionIdUsed ?? readString(params.sessionId) ?? "copilot-session",
|
||||
timedOut,
|
||||
timedOutDuringCompaction: state.timedOutDuringCompaction === true,
|
||||
timedOutDuringCompaction: false,
|
||||
toolMetas,
|
||||
yieldDetected: state.yieldDetected === true,
|
||||
};
|
||||
@@ -1080,14 +731,14 @@ function createSessionConfig(
|
||||
sdkModelId: string,
|
||||
sdkTools: SdkTool[],
|
||||
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
|
||||
systemMessageContent: string | undefined,
|
||||
workspaceBootstrapInstructions: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
|
||||
): CopilotSessionConfig {
|
||||
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
|
||||
const hooks = createHooksBridge(params.hooksConfig, hooksBridgeOptions);
|
||||
const hooks = createHooksBridge(params.hooksConfig);
|
||||
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);
|
||||
const systemMessageContent = createSystemMessageContent(params, workspaceBootstrapInstructions);
|
||||
return {
|
||||
model: sdkModelId,
|
||||
// Permission decisions for SDK built-in tool kinds (shell, write,
|
||||
@@ -1114,9 +765,10 @@ function createSessionConfig(
|
||||
// contract, omitting the handler hides the `ask_user` tool from the
|
||||
// model entirely. Interactive ask_user will need a real channel/TUI
|
||||
// prompt bridge before this runtime can expose the handler.
|
||||
// Preserve the shipped native SDK hook contract. These callbacks expose
|
||||
// Copilot-specific events and decisions that generic lifecycle hooks do
|
||||
// not model.
|
||||
// SessionHooks: only set when the host actually supplied handlers.
|
||||
// createHooksBridge returns undefined for an empty config so we
|
||||
// never install an empty hooks subsystem. See hooks-bridge.ts for
|
||||
// the back-pointer to src/agents/harness/lifecycle-hook-helpers.ts.
|
||||
...(hooks ? { hooks } : {}),
|
||||
// Session-level telemetry opt-out: only propagate when the host
|
||||
// explicitly set a boolean. undefined means "use SDK default"
|
||||
|
||||
@@ -15,8 +15,6 @@ const REGISTERED_EVENT_TYPES = [
|
||||
"assistant.usage",
|
||||
"tool.execution_start",
|
||||
"tool.execution_complete",
|
||||
"session.compaction_start",
|
||||
"session.compaction_complete",
|
||||
"session.error",
|
||||
"abort",
|
||||
] as const;
|
||||
@@ -607,88 +605,6 @@ describe("attachEventBridge", () => {
|
||||
expect(bridge.snapshot().toolMetas).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes compaction callbacks and clears active compaction state on completion", async () => {
|
||||
const session = createFakeSession();
|
||||
const calls: string[] = [];
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onCompactionStart: () => {
|
||||
calls.push("start");
|
||||
},
|
||||
onCompactionComplete: ({ success }) => {
|
||||
calls.push(`complete:${success}`);
|
||||
},
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
session.emit(
|
||||
"session.compaction_complete",
|
||||
makeEvent("session.compaction_complete", { success: false }),
|
||||
);
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
session.emit(
|
||||
"session.compaction_complete",
|
||||
makeEvent("session.compaction_complete", { success: true }),
|
||||
);
|
||||
await bridge.awaitCompactionChain();
|
||||
|
||||
expect(calls).toEqual(["start", "complete:false", "start", "complete:true"]);
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for an active compaction and its completion callback", async () => {
|
||||
const session = createFakeSession();
|
||||
const complete = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onCompactionComplete: complete,
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", makeEvent("session.compaction_start", {}));
|
||||
const completion = bridge.awaitCompactionCompletion();
|
||||
await flushAsync();
|
||||
|
||||
expect(bridge.hasObservedCompaction()).toBe(true);
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
session.emit(
|
||||
"session.compaction_complete",
|
||||
makeEvent("session.compaction_complete", { messagesRemoved: 3, success: true }),
|
||||
);
|
||||
await completion;
|
||||
|
||||
expect(complete).toHaveBeenCalledWith({ messagesRemoved: 3, success: true });
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores subagent compaction events when tracking the root session", async () => {
|
||||
const session = createFakeSession();
|
||||
const onCompactionStart = vi.fn();
|
||||
const onCompactionComplete = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onCompactionStart,
|
||||
onCompactionComplete,
|
||||
});
|
||||
|
||||
session.emit("session.compaction_start", {
|
||||
...makeEvent("session.compaction_start", {}),
|
||||
agentId: "subagent-1",
|
||||
});
|
||||
session.emit("session.compaction_complete", {
|
||||
...makeEvent("session.compaction_complete", { success: true }),
|
||||
agentId: "subagent-1",
|
||||
});
|
||||
await bridge.awaitCompactionCompletion();
|
||||
|
||||
expect(bridge.hasObservedCompaction()).toBe(false);
|
||||
expect(bridge.isCompacting()).toBe(false);
|
||||
expect(onCompactionStart).not.toHaveBeenCalled();
|
||||
expect(onCompactionComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("session.error populates streamError with errorCode or errorType only when not aborted", () => {
|
||||
const activeSession = createFakeSession();
|
||||
const activeBridge = attachEventBridge(activeSession, {
|
||||
|
||||
@@ -30,22 +30,12 @@ export interface SessionLike {
|
||||
): (() => void) | void;
|
||||
(eventType: string, handler: (event: SessionEvent) => void): (() => void) | void;
|
||||
};
|
||||
rpc?: {
|
||||
history?: {
|
||||
cancelBackgroundCompaction?: () => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined>;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface EventBridgeOptions {
|
||||
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
|
||||
onCompactionComplete?: (payload: {
|
||||
messagesRemoved?: number;
|
||||
success: boolean;
|
||||
}) => void | Promise<void>;
|
||||
onCompactionStart?: () => void | Promise<void>;
|
||||
getSdkSessionId: () => string | undefined;
|
||||
isAborted: () => boolean;
|
||||
}
|
||||
@@ -67,11 +57,7 @@ export interface BuildAssistantMessageArgs {
|
||||
|
||||
export interface EventBridgeController {
|
||||
recordSendResult(result: SessionEvent | undefined): boolean;
|
||||
awaitCompactionChain(): Promise<void>;
|
||||
awaitCompactionCompletion(): Promise<void>;
|
||||
awaitDeltaChain(): Promise<void>;
|
||||
hasObservedCompaction(): boolean;
|
||||
isCompacting(): boolean;
|
||||
snapshot(): EventBridgeSnapshot;
|
||||
buildAssistantMessage(args: BuildAssistantMessageArgs): AssistantMessage | undefined;
|
||||
finalizeAssistantTexts(): string[];
|
||||
@@ -96,13 +82,8 @@ export function attachEventBridge(
|
||||
const toolNamesByCallId = new Map<string, string>();
|
||||
let startedCount = 0;
|
||||
let completedCount = 0;
|
||||
let activeCompactionCount = 0;
|
||||
let observedCompaction = false;
|
||||
let deltaQueue = Promise.resolve();
|
||||
let deltaChain = Promise.resolve();
|
||||
let compactionChain = Promise.resolve();
|
||||
let compactionIdle = Promise.resolve();
|
||||
let resolveCompactionIdle: (() => void) | undefined;
|
||||
let firstDeltaError: unknown;
|
||||
let detached = false;
|
||||
const unsubscribeFns: Array<() => void> = [];
|
||||
@@ -183,39 +164,6 @@ export function attachEventBridge(
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
observedCompaction = true;
|
||||
if (activeCompactionCount === 0) {
|
||||
compactionIdle = new Promise<void>((resolve) => {
|
||||
resolveCompactionIdle = resolve;
|
||||
});
|
||||
}
|
||||
activeCompactionCount += 1;
|
||||
enqueueCompactionCallback(options.onCompactionStart);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_complete", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
activeCompactionCount = Math.max(0, activeCompactionCount - 1);
|
||||
enqueueCompactionCallback(() =>
|
||||
options.onCompactionComplete?.({
|
||||
...(event.data.messagesRemoved !== undefined
|
||||
? { messagesRemoved: event.data.messagesRemoved }
|
||||
: {}),
|
||||
success: event.data.success,
|
||||
}),
|
||||
);
|
||||
if (activeCompactionCount === 0) {
|
||||
resolveCompactionIdle?.();
|
||||
resolveCompactionIdle = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.error", (event) => {
|
||||
if (!options.isAborted()) {
|
||||
streamError = createPromptError(
|
||||
@@ -226,7 +174,6 @@ export function attachEventBridge(
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "abort", (event) => {
|
||||
finishCompactionObservation();
|
||||
if (!options.isAborted()) {
|
||||
streamError = createPromptError(
|
||||
"session_aborted",
|
||||
@@ -243,26 +190,9 @@ export function attachEventBridge(
|
||||
lastAssistantEvent = result;
|
||||
return true;
|
||||
},
|
||||
awaitCompactionChain() {
|
||||
return compactionChain;
|
||||
},
|
||||
async awaitCompactionCompletion() {
|
||||
// Background compaction can outlive session.idle. Keep the observer
|
||||
// attached until its completion callback has run before releasing the session.
|
||||
while (activeCompactionCount > 0) {
|
||||
await compactionIdle;
|
||||
}
|
||||
await compactionChain;
|
||||
},
|
||||
awaitDeltaChain() {
|
||||
return deltaChain;
|
||||
},
|
||||
hasObservedCompaction() {
|
||||
return observedCompaction;
|
||||
},
|
||||
isCompacting() {
|
||||
return activeCompactionCount > 0;
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent),
|
||||
@@ -303,20 +233,6 @@ export function attachEventBridge(
|
||||
unsubscribeFns.length = 0;
|
||||
},
|
||||
};
|
||||
|
||||
function enqueueCompactionCallback(callback: (() => void | Promise<void>) | undefined): void {
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const queued = compactionChain.then(callback, callback);
|
||||
compactionChain = queued.catch(() => undefined);
|
||||
}
|
||||
|
||||
function finishCompactionObservation(): void {
|
||||
activeCompactionCount = 0;
|
||||
resolveCompactionIdle?.();
|
||||
resolveCompactionIdle = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssistantMessage(params: {
|
||||
@@ -416,12 +332,6 @@ function isAssistantMessageEvent(
|
||||
return event?.type === "assistant.message";
|
||||
}
|
||||
|
||||
function isRootCompactionEvent(event: { agentId?: string }): boolean {
|
||||
// SDK session events include subagent compaction; only root compaction
|
||||
// affects the pooled root session's cleanup and reuse lifecycle.
|
||||
return event.agentId === undefined;
|
||||
}
|
||||
|
||||
function joinReasoning(order: string[], reasoningById: Map<string, string>): string {
|
||||
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copilot tests cover native SDK hook compatibility.
|
||||
// Copilot tests cover hooks bridge plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
|
||||
|
||||
@@ -10,23 +10,26 @@ describe("createHooksBridge", () => {
|
||||
workingDirectory: "/",
|
||||
};
|
||||
|
||||
it("returns undefined when no handlers are configured", () => {
|
||||
it("returns undefined when no config is provided", () => {
|
||||
expect(createHooksBridge()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when config has no handlers", () => {
|
||||
expect(createHooksBridge({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when only onHookError is supplied (no real handlers)", () => {
|
||||
expect(createHooksBridge({ onHookError: () => undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes only configured native handlers", () => {
|
||||
const hooks = createHooksBridge({
|
||||
onPreToolUse: vi.fn(),
|
||||
onSessionStart: vi.fn(),
|
||||
})!;
|
||||
|
||||
it("includes only the handlers that were configured", () => {
|
||||
const onPreToolUse = vi.fn();
|
||||
const onSessionStart = vi.fn();
|
||||
const hooks = createHooksBridge({ onPreToolUse, onSessionStart })!;
|
||||
expect(hooks).toBeDefined();
|
||||
expect(typeof hooks.onPreToolUse).toBe("function");
|
||||
expect(typeof hooks.onSessionStart).toBe("function");
|
||||
expect(hooks.onPreMcpToolCall).toBeUndefined();
|
||||
expect(hooks.onPostToolUse).toBeUndefined();
|
||||
expect(hooks.onPostToolUseFailure).toBeUndefined();
|
||||
expect(hooks.onUserPromptSubmitted).toBeUndefined();
|
||||
expect(hooks.onSessionEnd).toBeUndefined();
|
||||
expect(hooks.onErrorOccurred).toBeUndefined();
|
||||
@@ -44,62 +47,71 @@ describe("createHooksBridge", () => {
|
||||
toolName: "bash",
|
||||
toolArgs: { cmd: "ls" },
|
||||
};
|
||||
|
||||
await expect(hooks.onPreToolUse!(input, { sessionId: "sess-1" })).resolves.toEqual({
|
||||
permissionDecision: "allow",
|
||||
additionalContext: "ok",
|
||||
});
|
||||
const result = await hooks.onPreToolUse!(input, { sessionId: "sess-1" });
|
||||
expect(result).toEqual({ permissionDecision: "allow", additionalContext: "ok" });
|
||||
expect(onPreToolUse).toHaveBeenCalledTimes(1);
|
||||
expect(onPreToolUse).toHaveBeenCalledWith(input, { sessionId: "sess-1" });
|
||||
});
|
||||
|
||||
it("reports the effective prompt after a native prompt hook completes", async () => {
|
||||
const onUserPromptSubmitted = vi.fn().mockResolvedValue({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
const observedPrompt = vi.fn();
|
||||
const hooks = createHooksBridge(
|
||||
{ onUserPromptSubmitted },
|
||||
{ onUserPromptSubmitted: observedPrompt },
|
||||
)!;
|
||||
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hello" }, { sessionId: "s" }),
|
||||
).resolves.toEqual({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
expect(observedPrompt).toHaveBeenCalledWith({
|
||||
additionalContext: "Use the approved repository.",
|
||||
prompt: "Review the authentication change.",
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates synchronous and asynchronous handler failures", async () => {
|
||||
it("isolates synchronous throws: returns undefined and notifies onHookError", async () => {
|
||||
const onHookError = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onPostToolUse: () => {
|
||||
throw new Error("post boom");
|
||||
},
|
||||
onHookError,
|
||||
})!;
|
||||
const result = await hooks.onPostToolUse!(
|
||||
{ ...hookBase, toolName: "x", toolArgs: {}, toolResult: {} as never },
|
||||
{ sessionId: "s" },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(onHookError).toHaveBeenCalledTimes(1);
|
||||
expect(onHookError.mock.calls[0]?.[0]).toEqual({
|
||||
hookName: "onPostToolUse",
|
||||
error: expect.any(Error),
|
||||
});
|
||||
expect((onHookError.mock.calls[0][0]!.error as Error).message).toBe("post boom");
|
||||
});
|
||||
|
||||
it("isolates async rejections: returns undefined and notifies onHookError", async () => {
|
||||
const onHookError = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onUserPromptSubmitted: async () => {
|
||||
throw new Error("prompt boom");
|
||||
throw new Error("async boom");
|
||||
},
|
||||
onHookError,
|
||||
})!;
|
||||
|
||||
await expect(
|
||||
hooks.onPostToolUse!(
|
||||
{ ...hookBase, toolName: "x", toolArgs: {}, toolResult: {} as never },
|
||||
{ sessionId: "s" },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hi" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(onHookError).toHaveBeenCalledTimes(2);
|
||||
const result = await hooks.onUserPromptSubmitted!(
|
||||
{ ...hookBase, prompt: "hi" },
|
||||
{ sessionId: "s" },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(onHookError).toHaveBeenCalledTimes(1);
|
||||
expect(onHookError.mock.calls[0]?.[0]?.hookName).toBe("onUserPromptSubmitted");
|
||||
});
|
||||
|
||||
it("never lets the error notifier throw into the SDK", async () => {
|
||||
it("uses console.warn as the default onHookError", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
try {
|
||||
const hooks = createHooksBridge({
|
||||
onErrorOccurred: () => {
|
||||
throw new Error("default-error-handler");
|
||||
},
|
||||
})!;
|
||||
const result = await hooks.onErrorOccurred!(
|
||||
{ ...hookBase, error: "x", errorContext: "system", recoverable: true },
|
||||
{ sessionId: "s" },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("onErrorOccurred");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never throws when onHookError itself throws", async () => {
|
||||
const hooks = createHooksBridge({
|
||||
onSessionEnd: () => {
|
||||
throw new Error("hook boom");
|
||||
@@ -108,47 +120,41 @@ describe("createHooksBridge", () => {
|
||||
throw new Error("notifier boom");
|
||||
},
|
||||
})!;
|
||||
|
||||
await expect(
|
||||
hooks.onSessionEnd!({ ...hookBase, reason: "complete" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves native MCP and failed-tool callbacks", async () => {
|
||||
const onPreMcpToolCall = vi.fn();
|
||||
const onPostToolUseFailure = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onPreMcpToolCall,
|
||||
onPostToolUseFailure,
|
||||
})!;
|
||||
|
||||
await hooks.onPreMcpToolCall!({} as never, { sessionId: "s" });
|
||||
await hooks.onPostToolUseFailure!({} as never, { sessionId: "s" });
|
||||
|
||||
expect(onPreMcpToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(onPostToolUseFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves all supported SDK hook handlers", () => {
|
||||
it("preserves all six SDK hook handlers when supplied", async () => {
|
||||
const config: CopilotHooksConfig = {
|
||||
onPreToolUse: vi.fn().mockResolvedValue({ suppressOutput: true }),
|
||||
onPreMcpToolCall: vi.fn(),
|
||||
onPostToolUse: vi.fn().mockResolvedValue({ suppressOutput: false }),
|
||||
onPostToolUseFailure: vi.fn(),
|
||||
onUserPromptSubmitted: vi.fn().mockResolvedValue({ modifiedPrompt: "trimmed" }),
|
||||
onSessionStart: vi.fn().mockResolvedValue({ additionalContext: "context" }),
|
||||
onSessionEnd: vi.fn().mockResolvedValue({ sessionSummary: "done" }),
|
||||
onErrorOccurred: vi.fn().mockResolvedValue({ errorHandling: "retry" as const }),
|
||||
};
|
||||
const hooks = createHooksBridge(config)!;
|
||||
|
||||
expect(typeof hooks.onPreToolUse).toBe("function");
|
||||
expect(typeof hooks.onPreMcpToolCall).toBe("function");
|
||||
expect(typeof hooks.onPostToolUse).toBe("function");
|
||||
expect(typeof hooks.onPostToolUseFailure).toBe("function");
|
||||
expect(typeof hooks.onUserPromptSubmitted).toBe("function");
|
||||
expect(typeof hooks.onSessionStart).toBe("function");
|
||||
expect(typeof hooks.onSessionEnd).toBe("function");
|
||||
expect(typeof hooks.onErrorOccurred).toBe("function");
|
||||
});
|
||||
|
||||
it("forwards void returns transparently", async () => {
|
||||
const hooks = createHooksBridge({
|
||||
onSessionStart: () => undefined,
|
||||
})!;
|
||||
const result = await hooks.onSessionStart!({ ...hookBase, source: "new" }, { sessionId: "s" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not invoke unconfigured handlers' isolators", () => {
|
||||
const hooks = createHooksBridge({ onPreToolUse: () => undefined })!;
|
||||
// ensure the missing handlers are literally absent, not just nullable
|
||||
expect("onPostToolUse" in hooks).toBe(false);
|
||||
expect("onUserPromptSubmitted" in hooks).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,56 @@
|
||||
/**
|
||||
* Compatibility adapter for native Copilot SDK SessionHooks.
|
||||
* Hooks bridge for the copilot agent runtime.
|
||||
*
|
||||
* `hooksConfig` is a shipped Copilot-specific per-attempt API. It remains
|
||||
* separate from OpenClaw's generic lifecycle hooks because the SDK callbacks
|
||||
* expose native events and decisions that the portable hook contract does not.
|
||||
* BACK-POINTER: The host-side hook runner lives outside this package
|
||||
* boundary in `src/agents/harness/lifecycle-hook-helpers.ts` (uses the
|
||||
* plugin hook runner via `src/plugins/hook-runner-global.ts`). Per
|
||||
* proposal §266 (todo `hooks-bridge`), this module provides a small
|
||||
* contract surface that mirrors the SDK's `SessionHooks` shape; the
|
||||
* core wiring layer constructs handlers that call into
|
||||
* `runAgentHarnessLlmInputHook`, `runAgentHarnessLlmOutputHook`,
|
||||
* `runAgentHarnessAgentEndHook`, etc., and threads them through
|
||||
* `AttemptParamsLike.hooks`.
|
||||
*
|
||||
* Cross-package boundary note: the heavy host lifecycle helpers
|
||||
* cannot be imported here (`tsconfig.package-boundary.base.json`). The
|
||||
* bridge keeps the SDK hook contracts intact, wraps each provided
|
||||
* handler in an error-isolating envelope so a thrown host hook cannot
|
||||
* crash the SDK session, and returns a `SessionHooks` object that
|
||||
* `createSessionConfig` can plug into `SessionConfig.hooks`.
|
||||
*
|
||||
* Note on default omission: if no handlers are supplied, the bridge
|
||||
* returns `undefined` so that `SessionConfig.hooks` stays absent and
|
||||
* the SDK skips the entire hook subsystem (matches the "no hooks
|
||||
* installed" runtime behaviour the harness had pre-bridge).
|
||||
*/
|
||||
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
|
||||
// All hook handler types are derived from SessionHooks so this bridge
|
||||
// stays pinned to the same SDK source the rest of the harness uses,
|
||||
// without depending on the SDK re-exporting individual handler aliases
|
||||
// (which it does not, as of @github/copilot-sdk@1.0.0-beta.4).
|
||||
type SdkSessionHooks = NonNullable<SessionConfig["hooks"]>;
|
||||
type PreToolUseHandler = NonNullable<SdkSessionHooks["onPreToolUse"]>;
|
||||
type PreMcpToolCallHandler = NonNullable<SdkSessionHooks["onPreMcpToolCall"]>;
|
||||
type PostToolUseHandler = NonNullable<SdkSessionHooks["onPostToolUse"]>;
|
||||
type PostToolUseFailureHandler = NonNullable<SdkSessionHooks["onPostToolUseFailure"]>;
|
||||
type UserPromptSubmittedHandler = NonNullable<SdkSessionHooks["onUserPromptSubmitted"]>;
|
||||
type SessionStartHandler = NonNullable<SdkSessionHooks["onSessionStart"]>;
|
||||
type SessionEndHandler = NonNullable<SdkSessionHooks["onSessionEnd"]>;
|
||||
type ErrorOccurredHandler = NonNullable<SdkSessionHooks["onErrorOccurred"]>;
|
||||
|
||||
export interface CopilotHooksBridgeOptions {
|
||||
onUserPromptSubmitted?: (submission: { prompt: string; additionalContext?: string }) => void;
|
||||
}
|
||||
|
||||
export interface CopilotHooksConfig {
|
||||
onPreToolUse?: PreToolUseHandler;
|
||||
onPreMcpToolCall?: PreMcpToolCallHandler;
|
||||
onPostToolUse?: PostToolUseHandler;
|
||||
onPostToolUseFailure?: PostToolUseFailureHandler;
|
||||
onUserPromptSubmitted?: UserPromptSubmittedHandler;
|
||||
onSessionStart?: SessionStartHandler;
|
||||
onSessionEnd?: SessionEndHandler;
|
||||
onErrorOccurred?: ErrorOccurredHandler;
|
||||
/**
|
||||
* Called when a native SDK hook handler throws. Defaults to console.warn so
|
||||
* native hook failures do not terminate the SDK session.
|
||||
* Optional hook-error notifier. Called whenever any wrapped handler
|
||||
* throws (synchronously or as a Promise rejection). Defaults to
|
||||
* `console.warn` so the failure is visible to operators without
|
||||
* crashing the SDK session. Receives the SDK hook name and the
|
||||
* raised error.
|
||||
*/
|
||||
onHookError?: (info: { hookName: keyof SdkSessionHooks; error: unknown }) => void;
|
||||
}
|
||||
@@ -45,8 +63,10 @@ const DEFAULT_HOOK_ERROR_HANDLER: NonNullable<CopilotHooksConfig["onHookError"]>
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a native handler so it cannot throw into the SDK. Returning undefined
|
||||
* leaves the SDK's default decision in place.
|
||||
* Wrap a host handler in an error-isolating envelope so it cannot
|
||||
* throw out into the SDK. Returns `undefined` (no opinion) when the
|
||||
* host handler throws, so the SDK falls back to its default behaviour
|
||||
* for that hook.
|
||||
*/
|
||||
function isolate<TArgs extends readonly unknown[], TResult>(
|
||||
hookName: keyof SdkSessionHooks,
|
||||
@@ -63,7 +83,7 @@ function isolate<TArgs extends readonly unknown[], TResult>(
|
||||
try {
|
||||
onError({ hookName, error });
|
||||
} catch {
|
||||
// Never let the error notifier itself throw into the SDK.
|
||||
// never let the error notifier itself throw out
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -71,22 +91,18 @@ function isolate<TArgs extends readonly unknown[], TResult>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SDK-shaped hook object from native per-attempt configuration.
|
||||
* Omit the SDK hook subsystem when no handlers were configured.
|
||||
* Build an SDK-shaped `SessionHooks` object from a host-supplied
|
||||
* `CopilotHooksConfig`. Returns `undefined` when no handlers were
|
||||
* supplied so the SDK skips the hook subsystem entirely.
|
||||
*/
|
||||
export function createHooksBridge(
|
||||
config?: CopilotHooksConfig,
|
||||
options?: CopilotHooksBridgeOptions,
|
||||
): SdkSessionHooks | undefined {
|
||||
export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const onError = config.onHookError ?? DEFAULT_HOOK_ERROR_HANDLER;
|
||||
const hooks: SdkSessionHooks = {};
|
||||
const pre = isolate("onPreToolUse", config.onPreToolUse, onError);
|
||||
const preMcp = isolate("onPreMcpToolCall", config.onPreMcpToolCall, onError);
|
||||
const post = isolate("onPostToolUse", config.onPostToolUse, onError);
|
||||
const postFailure = isolate("onPostToolUseFailure", config.onPostToolUseFailure, onError);
|
||||
const userPrompt = isolate("onUserPromptSubmitted", config.onUserPromptSubmitted, onError);
|
||||
const sessionStart = isolate("onSessionStart", config.onSessionStart, onError);
|
||||
const sessionEnd = isolate("onSessionEnd", config.onSessionEnd, onError);
|
||||
@@ -95,32 +111,11 @@ export function createHooksBridge(
|
||||
if (pre) {
|
||||
hooks.onPreToolUse = pre as PreToolUseHandler;
|
||||
}
|
||||
if (preMcp) {
|
||||
hooks.onPreMcpToolCall = preMcp as PreMcpToolCallHandler;
|
||||
}
|
||||
if (post) {
|
||||
hooks.onPostToolUse = post as PostToolUseHandler;
|
||||
}
|
||||
if (postFailure) {
|
||||
hooks.onPostToolUseFailure = postFailure as PostToolUseFailureHandler;
|
||||
}
|
||||
if (userPrompt) {
|
||||
hooks.onUserPromptSubmitted = async (input, invocation) => {
|
||||
const output = await userPrompt(input, invocation);
|
||||
try {
|
||||
options?.onUserPromptSubmitted?.({
|
||||
prompt: output?.modifiedPrompt ?? input.prompt,
|
||||
...(output?.additionalContext ? { additionalContext: output.additionalContext } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
onError({ hookName: "onUserPromptSubmitted", error });
|
||||
} catch {
|
||||
// Never let an observer or its error notifier throw into the SDK.
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
hooks.onUserPromptSubmitted = userPrompt as UserPromptSubmittedHandler;
|
||||
}
|
||||
if (sessionStart) {
|
||||
hooks.onSessionStart = sessionStart as SessionStartHandler;
|
||||
@@ -132,5 +127,8 @@ export function createHooksBridge(
|
||||
hooks.onErrorOccurred = errorOccurred as ErrorOccurredHandler;
|
||||
}
|
||||
|
||||
return Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
if (Object.keys(hooks).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
@@ -1175,19 +1175,15 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
|
||||
it("calls prepareArguments and passes the prepared args and toolCallId to execute", async () => {
|
||||
const preparedArgs = { value: "prepared" };
|
||||
const onToolCompleted = vi.fn();
|
||||
const prepareArguments = vi.fn(() => preparedArgs);
|
||||
const sourceTool = makeTool({ prepareArguments });
|
||||
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { onToolCompleted });
|
||||
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {});
|
||||
|
||||
await runSdkTool(sdkTool, { value: "raw" }, makeInvocation({ toolCallId: "call-99" }));
|
||||
|
||||
expect(prepareArguments).toHaveBeenCalledTimes(1);
|
||||
expect(prepareArguments).toHaveBeenCalledWith({ value: "raw" });
|
||||
expect(sourceTool.execute).toHaveBeenCalledWith("call-99", preparedArgs, undefined, undefined);
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: preparedArgs, toolCallId: "call-99" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a failure result when prepareArguments throws", async () => {
|
||||
@@ -1236,29 +1232,6 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports terminal tool results to the harness lifecycle bridge", async () => {
|
||||
const onToolCompleted = vi.fn();
|
||||
const sourceResult = {
|
||||
content: [{ text: "hello", type: "text" }],
|
||||
details: { results: [{ text: "hello" }] },
|
||||
};
|
||||
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
|
||||
onToolCompleted,
|
||||
});
|
||||
|
||||
await runSdkTool(sdkTool, { value: "input" }, makeInvocation({ toolCallId: "call-9" }));
|
||||
await flushAsync();
|
||||
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: { value: "input" },
|
||||
result: sourceResult,
|
||||
toolCallId: "call-9",
|
||||
toolName: "tool-a",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports thrown tool failures to the private result observer", async () => {
|
||||
const error = new Error("backend unavailable");
|
||||
const onAgentToolResult = vi.fn();
|
||||
@@ -1288,46 +1261,17 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports terminal tool failures to the harness lifecycle bridge", async () => {
|
||||
const onToolCompleted = vi.fn();
|
||||
const preparedArgs = { value: "prepared" };
|
||||
const sdkTool = convertOpenClawToolToSdkTool(
|
||||
makeTool({
|
||||
prepareArguments: vi.fn(() => preparedArgs),
|
||||
execute: vi.fn(async () => {
|
||||
throw new Error("backend unavailable");
|
||||
}),
|
||||
}),
|
||||
{ onToolCompleted },
|
||||
);
|
||||
|
||||
await runSdkTool(sdkTool, { value: "input" }, makeInvocation({ toolCallId: "call-10" }));
|
||||
await flushAsync();
|
||||
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: preparedArgs,
|
||||
error: "backend unavailable",
|
||||
toolCallId: "call-10",
|
||||
toolName: "tool-a",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports returned OpenClaw error results to both tool observers", async () => {
|
||||
it("reports returned OpenClaw error results as observer failures", async () => {
|
||||
const onAgentToolResult = vi.fn();
|
||||
const onToolCompleted = vi.fn();
|
||||
const sourceResult = {
|
||||
content: [{ text: '{"status":"error","error":"backend unavailable"}', type: "text" }],
|
||||
details: { status: "error", error: "backend unavailable" },
|
||||
};
|
||||
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
|
||||
onAgentToolResult,
|
||||
onToolCompleted,
|
||||
});
|
||||
|
||||
const result = await runSdkTool(sdkTool, {});
|
||||
await flushAsync();
|
||||
|
||||
expect(result).toMatchObject({ resultType: "success" });
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
@@ -1335,12 +1279,6 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
result: sourceResult,
|
||||
isError: true,
|
||||
});
|
||||
expect(onToolCompleted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "backend unavailable",
|
||||
result: sourceResult,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("joins multiple text blocks with newlines", async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
import {
|
||||
applyEmbeddedAttemptToolsAllow,
|
||||
buildEmbeddedAttemptToolRunContext,
|
||||
extractToolErrorMessage,
|
||||
getPluginToolMeta,
|
||||
isSubagentSessionKey,
|
||||
isToolResultError,
|
||||
@@ -54,15 +53,6 @@ export interface CopilotSessionHolder {
|
||||
*/
|
||||
export type CopilotToolAttemptParams = Partial<EmbeddedRunAttemptParams>;
|
||||
|
||||
export type CopilotToolCompletion = {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export interface CopilotToolBridgeInput {
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
@@ -118,7 +108,6 @@ export interface CopilotToolBridgeInput {
|
||||
* `extensions/codex/src/app-server/run-attempt.ts:539-541`.
|
||||
*/
|
||||
onYieldDetected?: (message?: string) => void;
|
||||
onToolCompleted?: (completion: CopilotToolCompletion) => void | Promise<void>;
|
||||
createOpenClawCodingTools?: (opts: unknown) => AnyAgentTool[] | Promise<AnyAgentTool[]>;
|
||||
beforeExecute?: (ctx: {
|
||||
toolName: string;
|
||||
@@ -219,7 +208,6 @@ export async function createCopilotToolBridge(
|
||||
abortSignal: input.abortSignal,
|
||||
beforeExecute: input.beforeExecute,
|
||||
onAgentToolResult: input.attemptParams?.onAgentToolResult,
|
||||
onToolCompleted: input.onToolCompleted,
|
||||
}),
|
||||
),
|
||||
sourceTools: filteredTools,
|
||||
@@ -401,7 +389,6 @@ export function convertOpenClawToolToSdkTool(
|
||||
abortSignal?: AbortSignal;
|
||||
beforeExecute?: CopilotToolBridgeInput["beforeExecute"];
|
||||
onAgentToolResult?: CopilotToolAttemptParams["onAgentToolResult"];
|
||||
onToolCompleted?: CopilotToolBridgeInput["onToolCompleted"];
|
||||
},
|
||||
): SdkTool {
|
||||
if (typeof sourceTool.name !== "string" || sourceTool.name.trim().length === 0) {
|
||||
@@ -422,47 +409,23 @@ export function convertOpenClawToolToSdkTool(
|
||||
console.warn("[copilot-tool-bridge] onAgentToolResult handler threw; continuing", error);
|
||||
}
|
||||
};
|
||||
const notifyToolCompleted = (completion: CopilotToolCompletion) => {
|
||||
try {
|
||||
void Promise.resolve(ctx.onToolCompleted?.(completion)).catch((error) => {
|
||||
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
|
||||
}
|
||||
};
|
||||
const failureResult = (
|
||||
executedArgs: unknown,
|
||||
invocation: ToolInvocation,
|
||||
startedAt: number,
|
||||
message: string,
|
||||
error: unknown,
|
||||
): ToolResultObject => {
|
||||
const errorMessage = toError(error).message;
|
||||
const failureResult = (message: string, error: unknown): ToolResultObject => {
|
||||
notifyToolResult(
|
||||
sanitizeToolResult({
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: errorMessage },
|
||||
details: { status: "failed", error: toError(error).message },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
notifyToolCompleted({
|
||||
toolName: sourceTool.name,
|
||||
toolCallId: invocation.toolCallId,
|
||||
args: toToolStartArgs(executedArgs),
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
});
|
||||
return createFailureResult(message, error);
|
||||
};
|
||||
const executeOnce = async (
|
||||
args: unknown,
|
||||
invocation: ToolInvocation,
|
||||
): Promise<ToolResultObject> => {
|
||||
const startedAt = Date.now();
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
const error = new Error("[copilot-tool-bridge] aborted before execution");
|
||||
return failureResult(args, invocation, startedAt, error.message, error);
|
||||
return failureResult(error.message, error);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -475,9 +438,6 @@ export function convertOpenClawToolToSdkTool(
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
args,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -488,9 +448,6 @@ export function convertOpenClawToolToSdkTool(
|
||||
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
args,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -506,9 +463,6 @@ export function convertOpenClawToolToSdkTool(
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
preparedArgs,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -516,17 +470,10 @@ export function convertOpenClawToolToSdkTool(
|
||||
|
||||
const sdkResult = agentToolResultToSdk(result);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const resultIsError = sdkResult.resultType === "failure" || isToolResultError(sanitizedResult);
|
||||
const resultError = resultIsError ? extractToolErrorMessage(sanitizedResult) : undefined;
|
||||
notifyToolResult(sanitizedResult, resultIsError);
|
||||
notifyToolCompleted({
|
||||
toolName: sourceTool.name,
|
||||
toolCallId: invocation.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
result: sanitizedResult,
|
||||
...(resultError ? { error: resultError } : {}),
|
||||
startedAt,
|
||||
});
|
||||
notifyToolResult(
|
||||
sanitizedResult,
|
||||
sdkResult.resultType === "failure" || isToolResultError(sanitizedResult),
|
||||
);
|
||||
return sdkResult;
|
||||
};
|
||||
|
||||
@@ -575,12 +522,6 @@ export function convertOpenClawToolToSdkTool(
|
||||
};
|
||||
}
|
||||
|
||||
function toToolStartArgs(args: unknown): Record<string, unknown> {
|
||||
return args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, unknown>)
|
||||
: { value: args };
|
||||
}
|
||||
|
||||
function agentToolResultToSdk(result: AgentToolResultLike | undefined): ToolResultObject {
|
||||
const content = result?.content;
|
||||
if (content == null) {
|
||||
|
||||
@@ -13,7 +13,6 @@ export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
|
||||
"text",
|
||||
"ansi",
|
||||
] as const satisfies readonly SupportedLanguages[];
|
||||
export type DiffViewerBaseLanguage = (typeof BASE_DIFF_VIEWER_LANGUAGE_HINTS)[number];
|
||||
|
||||
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
|
||||
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(
|
||||
|
||||
@@ -5,6 +5,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DiscordApiError, fetchDiscord, requestDiscord } from "./api.js";
|
||||
import { jsonResponse } from "./test-http-helpers.js";
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -48,6 +70,31 @@ describe("fetchDiscord", () => {
|
||||
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
|
||||
});
|
||||
|
||||
it("bounds Discord API error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"discord api unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
const fetcher = withFetchPreconnect(async () => tracked.response);
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
|
||||
retry: { attempts: 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(DiscordApiError);
|
||||
expect(String(error)).toContain("Discord API /users/@me/guilds failed (503)");
|
||||
expect(String(error)).toContain("discord api unavailable");
|
||||
expect(String(error)).not.toContain("tail");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitizes Cloudflare HTML rate limits and applies a fallback cooldown", async () => {
|
||||
const fetcher = withFetchPreconnect(
|
||||
async () =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Discord API module exposes the plugin public contract.
|
||||
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
resolveRetryConfig,
|
||||
retryAsync,
|
||||
@@ -17,6 +18,7 @@ const DISCORD_API_RETRY_DEFAULTS = {
|
||||
jitter: 0.1,
|
||||
};
|
||||
const DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS = 60;
|
||||
const DISCORD_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
type DiscordApiErrorPayload = {
|
||||
message?: string;
|
||||
@@ -173,8 +175,10 @@ export async function requestDiscord<T>(
|
||||
body,
|
||||
signal: resolveDiscordRequestSignal(options ?? {}),
|
||||
});
|
||||
const text = await res.text().catch(() => "");
|
||||
if (!res.ok) {
|
||||
const text = await readResponseTextLimited(res, DISCORD_API_ERROR_BODY_LIMIT_BYTES).catch(
|
||||
() => "",
|
||||
);
|
||||
const detail = formatDiscordApiErrorText(text, res);
|
||||
const suffix = detail ? `: ${detail}` : "";
|
||||
const retryAfter =
|
||||
@@ -187,6 +191,7 @@ export async function requestDiscord<T>(
|
||||
retryAfter,
|
||||
);
|
||||
}
|
||||
const text = await res.text().catch(() => "");
|
||||
if (!text.trim()) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Discord plugin module implements client behavior.
|
||||
import type { APIApplicationCommand, APIInteraction } from "discord-api-types/v10";
|
||||
import type { APIInteraction } from "discord-api-types/v10";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { DiscordCommandDeployer, type DeployCommandOptions } from "./command-deploy.js";
|
||||
import type { BaseCommand } from "./commands.js";
|
||||
@@ -272,18 +272,10 @@ export class Client {
|
||||
return await this.entityCache.fetchMember(guildId, userId);
|
||||
}
|
||||
|
||||
async getDiscordCommands(): Promise<APIApplicationCommand[]> {
|
||||
return await this.commandDeployer.getCommands();
|
||||
}
|
||||
|
||||
async deployCommands(options: DeployCommandOptions = {}) {
|
||||
return await this.commandDeployer.deploy(options);
|
||||
}
|
||||
|
||||
async reconcileCommands() {
|
||||
return await this.deployCommands({ mode: "reconcile" });
|
||||
}
|
||||
|
||||
async handleInteraction(rawData: APIInteraction, _ctx?: Context): Promise<void> {
|
||||
await dispatchInteraction(this, rawData);
|
||||
}
|
||||
|
||||
@@ -144,9 +144,6 @@ export abstract class Command extends BaseCommand {
|
||||
`The ${(interaction as { rawData?: { data?: { name?: string } } }).rawData?.data?.name ?? this.name} command does not support autocomplete`,
|
||||
);
|
||||
}
|
||||
async preCheck(interaction: unknown): Promise<unknown> {
|
||||
return Boolean(interaction) || true;
|
||||
}
|
||||
serializeOptions() {
|
||||
return this.options?.map((option) => {
|
||||
if (typeof option.autocomplete === "function") {
|
||||
|
||||
@@ -138,12 +138,6 @@ export class Row<T extends BaseMessageInteractiveComponent> extends BaseComponen
|
||||
addComponent(component: T): void {
|
||||
this.components.push(component);
|
||||
}
|
||||
removeComponent(component: T): void {
|
||||
this.components = this.components.filter((entry) => entry !== component);
|
||||
}
|
||||
removeAllComponents(): void {
|
||||
this.components = [];
|
||||
}
|
||||
serialize(): APIActionRowComponent<APIComponentInMessageActionRow> {
|
||||
return {
|
||||
type: this.type,
|
||||
|
||||
@@ -462,18 +462,6 @@ export class GatewayPlugin extends Plugin {
|
||||
return this.outboundLimiter.getStatus();
|
||||
}
|
||||
|
||||
getIntentsInfo() {
|
||||
const intents = this.options.intents ?? 0;
|
||||
return {
|
||||
intents,
|
||||
hasGuilds: this.hasIntent(GatewayIntentBits.Guilds),
|
||||
hasGuildMembers: this.hasIntent(GatewayIntentBits.GuildMembers),
|
||||
hasGuildPresences: this.hasIntent(GatewayIntentBits.GuildPresences),
|
||||
hasGuildMessages: this.hasIntent(GatewayIntentBits.GuildMessages),
|
||||
hasMessageContent: this.hasIntent(GatewayIntentBits.MessageContent),
|
||||
};
|
||||
}
|
||||
|
||||
hasIntent(intent: number): boolean {
|
||||
return Boolean((this.options.intents ?? 0) & intent);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import {
|
||||
createInteractionCallback,
|
||||
createWebhookMessage,
|
||||
deleteWebhookMessage,
|
||||
editWebhookMessage,
|
||||
getWebhookMessage,
|
||||
} from "./api.js";
|
||||
@@ -209,15 +208,6 @@ export class BaseInteraction {
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteReply(): Promise<unknown> {
|
||||
return await deleteWebhookMessage(
|
||||
this.client.rest,
|
||||
this.client.options.clientId,
|
||||
this.token,
|
||||
"@original",
|
||||
);
|
||||
}
|
||||
|
||||
async fetchReply(): Promise<unknown> {
|
||||
return await getWebhookMessage(
|
||||
this.client.rest,
|
||||
@@ -293,18 +283,6 @@ export class BaseComponentInteraction extends BaseInteraction {
|
||||
async showModal(modal: Modal): Promise<unknown> {
|
||||
return await this.callback(InteractionResponseType.Modal, modal.serialize());
|
||||
}
|
||||
|
||||
async editAndWaitForComponent(
|
||||
payload: MessagePayload,
|
||||
message: Message | null = this.message,
|
||||
timeoutMs = 300_000,
|
||||
) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
const editedMessage = await message.edit(payload);
|
||||
return await this.client.componentHandler.waitForMessageComponent(editedMessage, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
export class ButtonInteraction extends BaseComponentInteraction {}
|
||||
|
||||
@@ -148,12 +148,6 @@ export function createDiscordDraftPreviewController(params: {
|
||||
finalizedViaPreviewMessage = true;
|
||||
},
|
||||
disableBlockStreamingForDraft: draftStream ? true : undefined,
|
||||
async startProgressDraft() {
|
||||
if (!draftStream || discordStreamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
await progressDraft.start();
|
||||
},
|
||||
async pushToolProgress(
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string },
|
||||
|
||||
@@ -16,16 +16,16 @@ describe("formatDiscordReplySkip", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the reasoning-payload reason with the same shape", () => {
|
||||
it("renders the internal-only-payload reason with the same shape", () => {
|
||||
expect(
|
||||
formatDiscordReplySkip({
|
||||
kind: "block",
|
||||
reason: "reasoning payload",
|
||||
reason: "internal-only payload",
|
||||
target: "channel:456",
|
||||
sessionKey: "agent:friday:discord:channel:456",
|
||||
}),
|
||||
).toBe(
|
||||
"discord block reply skipped (reasoning payload): target=channel:456 session=agent:friday:discord:channel:456",
|
||||
"discord block reply skipped (internal-only payload): target=channel:456 session=agent:friday:discord:channel:456",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,11 +43,11 @@ describe("formatDiscordReplySkip", () => {
|
||||
expect(
|
||||
formatDiscordReplySkip({
|
||||
kind: "tool",
|
||||
reason: "reasoning payload",
|
||||
reason: "internal-only payload",
|
||||
target: "channel:c1",
|
||||
sessionKey: "",
|
||||
}),
|
||||
).toBe("discord tool reply skipped (reasoning payload): target=channel:c1");
|
||||
).toBe("discord tool reply skipped (internal-only payload): target=channel:c1");
|
||||
});
|
||||
|
||||
it("preserves the kind discriminant in the message prefix", () => {
|
||||
|
||||
@@ -2639,17 +2639,20 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses reasoning payload delivery to Discord", async () => {
|
||||
it("delivers reasoning block payloads to Discord", async () => {
|
||||
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
|
||||
await processStreamOffDiscordMessage();
|
||||
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [{ text: "thinking...", isReasoning: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses reasoning-tagged final payload delivery to Discord", async () => {
|
||||
it("delivers reasoning-tagged final payload to Discord", async () => {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({
|
||||
text: "Reasoning:\nthis should stay internal",
|
||||
text: "Reasoning:\nthis should be visible",
|
||||
isReasoning: true,
|
||||
});
|
||||
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
|
||||
@@ -2661,8 +2664,10 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
expect(editMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [{ text: "this should be visible", isReasoning: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers non-reasoning block payloads to Discord", async () => {
|
||||
@@ -3057,8 +3062,8 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
|
||||
expect(lastUpdate).toContain("completed");
|
||||
expect(lastUpdate).not.toContain("install dependencies");
|
||||
expect(lastUpdate).toContain("install dependencies");
|
||||
expect(lastUpdate).not.toContain("completed");
|
||||
});
|
||||
|
||||
it("drops later tool warning finals after progress preview final replies", async () => {
|
||||
|
||||
@@ -113,10 +113,7 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
|
||||
return !resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||
}
|
||||
|
||||
type DiscordReplySkipReason =
|
||||
| "aborted before delivery"
|
||||
| "reasoning payload"
|
||||
| "internal-only payload";
|
||||
type DiscordReplySkipReason = "aborted before delivery" | "internal-only payload";
|
||||
|
||||
export function formatDiscordReplySkip(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
@@ -609,18 +606,6 @@ async function processDiscordMessageInner(
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (payload.isReasoning) {
|
||||
// Reasoning/thinking payloads should not be delivered to Discord.
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
reason: "reasoning payload",
|
||||
target: deliverTarget,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") {
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
if (!reply.hasMedia && !payload.isError) {
|
||||
@@ -652,18 +637,6 @@ async function processDiscordMessageInner(
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
const isFinal = info.kind === "final";
|
||||
if (payload.isReasoning) {
|
||||
// Reasoning/thinking payloads should not be delivered to Discord.
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
reason: "reasoning payload",
|
||||
target: deliverTarget,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
}),
|
||||
);
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
if (
|
||||
isFinal &&
|
||||
!options?.allowFallbackOnlyToolWarning &&
|
||||
|
||||
@@ -90,8 +90,6 @@ let discordProviderSessionRuntimePromise: Promise<DiscordProviderSessionRuntimeM
|
||||
let fetchDiscordApplicationIdForTesting: typeof fetchDiscordApplicationId | undefined;
|
||||
let createDiscordNativeCommandForTesting: typeof createDiscordNativeCommand | undefined;
|
||||
let runDiscordGatewayLifecycleForTesting: typeof runDiscordGatewayLifecycle | undefined;
|
||||
let createDiscordGatewayPluginForTesting: typeof createDiscordGatewayPlugin | undefined;
|
||||
let createDiscordGatewaySupervisorForTesting: typeof createDiscordGatewaySupervisor | undefined;
|
||||
let loadDiscordVoiceRuntimeForTesting: (() => Promise<DiscordVoiceRuntimeModule>) | undefined;
|
||||
let loadDiscordProviderSessionRuntimeForTesting:
|
||||
| (() => Promise<DiscordProviderSessionRuntimeModule>)
|
||||
@@ -437,9 +435,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
discordConfig: discordCfg,
|
||||
runtime,
|
||||
createClient: createClientForTesting ?? ((...args) => new Client(...args)),
|
||||
createGatewayPlugin: createDiscordGatewayPluginForTesting ?? createDiscordGatewayPlugin,
|
||||
createGatewaySupervisor:
|
||||
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor,
|
||||
createGatewayPlugin: createDiscordGatewayPlugin,
|
||||
createGatewaySupervisor: createDiscordGatewaySupervisor,
|
||||
createAutoPresenceController: createDiscordAutoPresenceController,
|
||||
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
|
||||
});
|
||||
@@ -643,12 +640,6 @@ export const testing = {
|
||||
setRunDiscordGatewayLifecycle(mock?: typeof runDiscordGatewayLifecycle) {
|
||||
runDiscordGatewayLifecycleForTesting = mock;
|
||||
},
|
||||
setCreateDiscordGatewayPlugin(mock?: typeof createDiscordGatewayPlugin) {
|
||||
createDiscordGatewayPluginForTesting = mock;
|
||||
},
|
||||
setCreateDiscordGatewaySupervisor(mock?: typeof createDiscordGatewaySupervisor) {
|
||||
createDiscordGatewaySupervisorForTesting = mock;
|
||||
},
|
||||
setLoadDiscordVoiceRuntime(mock?: () => Promise<DiscordVoiceRuntimeModule>) {
|
||||
loadDiscordVoiceRuntimeForTesting = mock;
|
||||
},
|
||||
|
||||
@@ -141,6 +141,21 @@ describe("deliverDiscordReply", () => {
|
||||
expect(sendOptions.rest).toBe(rest);
|
||||
});
|
||||
|
||||
it("formats reasoning replies as visible Discord payloads before shared outbound", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Because it helps", isReasoning: true }],
|
||||
target: "channel:101",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
kind: "block",
|
||||
});
|
||||
|
||||
expect(firstDeliverParams().payloads).toEqual([{ text: "Thinking\n\n_Because it helps_" }]);
|
||||
});
|
||||
|
||||
it("fails when shared outbound accepts a final reply but delivers no Discord message", async () => {
|
||||
sendDurableMessageBatchMock.mockResolvedValueOnce({ status: "sent", results: [] });
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Discord plugin module implements reply delivery behavior.
|
||||
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { formatReasoningMessage, resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
buildOutboundSessionContext,
|
||||
sendDurableMessageBatch,
|
||||
@@ -156,6 +156,19 @@ function resolveDiscordDeliveryOptions(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDiscordReasoningPayload(payload: ReplyPayload): ReplyPayload {
|
||||
if (payload.isReasoning !== true) {
|
||||
return payload;
|
||||
}
|
||||
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||
const nextPayload: ReplyPayload = {
|
||||
...payload,
|
||||
text: formatReasoningMessage(text),
|
||||
};
|
||||
delete nextPayload.isReasoning;
|
||||
return nextPayload;
|
||||
}
|
||||
|
||||
export async function deliverDiscordReply(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
@@ -178,7 +191,9 @@ export async function deliverDiscordReply(params: {
|
||||
void params.runtime;
|
||||
|
||||
const delivery = resolveDiscordDeliveryOptions(params);
|
||||
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, { kind: params.kind });
|
||||
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, {
|
||||
kind: params.kind,
|
||||
}).map(formatDiscordReasoningPayload);
|
||||
if (payloads.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,6 @@ export type PersistedThreadBindingRecord = ThreadBindingRecord & {
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export type PersistedThreadBindingsPayload = {
|
||||
version: 1;
|
||||
bindings: Record<string, PersistedThreadBindingRecord>;
|
||||
};
|
||||
|
||||
export type ThreadBindingManager = {
|
||||
accountId: string;
|
||||
getIdleTimeoutMs: () => number;
|
||||
|
||||
@@ -20,6 +20,28 @@ const buildResponse = (params: { status: number; body?: unknown }): MockResponse
|
||||
};
|
||||
};
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchPluralKitMessageInfo", () => {
|
||||
it("returns null when disabled", async () => {
|
||||
const fetcher = vi.fn();
|
||||
@@ -65,4 +87,30 @@ describe("fetchPluralKitMessageInfo", () => {
|
||||
expect(result?.member?.id).toBe("mem_1");
|
||||
expect(receivedHeaders?.Authorization).toBe("pk_test");
|
||||
});
|
||||
|
||||
it("bounds PluralKit API error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"plural failure ".repeat(1024)}tail`, {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
const fetcher = vi.fn(async () => tracked.response);
|
||||
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
await fetchPluralKitMessageInfo({
|
||||
messageId: "boom",
|
||||
config: { enabled: true },
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error as Error;
|
||||
}
|
||||
|
||||
expect(caught?.message).toContain("PluralKit API failed (500): plural failure");
|
||||
expect(caught?.message).not.toContain("tail");
|
||||
expect(caught?.message.length).toBeLessThan(8_400);
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Discord plugin module implements pluralkit behavior.
|
||||
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
|
||||
const PLURALKIT_API_BASE = "https://api.pluralkit.me/v2";
|
||||
const PLURALKIT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
export type DiscordPluralKitConfig = {
|
||||
enabled?: boolean;
|
||||
@@ -51,7 +53,9 @@ export async function fetchPluralKitMessageInfo(params: {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
const text = await readResponseTextLimited(res, PLURALKIT_ERROR_BODY_LIMIT_BYTES).catch(
|
||||
() => "",
|
||||
);
|
||||
const detail = text.trim() ? `: ${text.trim()}` : "";
|
||||
throw new Error(`PluralKit API failed (${res.status})${detail}`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchDiscordApplicationId,
|
||||
fetchDiscordApplicationSummary,
|
||||
probeDiscord,
|
||||
resolveDiscordPrivilegedIntentsFromFlags,
|
||||
} from "./probe.js";
|
||||
import { jsonResponse } from "./test-http-helpers.js";
|
||||
@@ -89,6 +90,20 @@ describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("cancels failed getMe probe response bodies", async () => {
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
const fetcher = withFetchPreconnect(
|
||||
async () => ({ ok: false, status: 401, body: { cancel } }) as unknown as Response,
|
||||
);
|
||||
|
||||
await expect(probeDiscord("MTIz.abc.def", 1_000, { fetcher })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: 401,
|
||||
error: "getMe failed (401)",
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("derives application id from parseable tokens before probing REST", async () => {
|
||||
let calls = 0;
|
||||
const fetcher = withFetchPreconnect(async () => {
|
||||
|
||||
@@ -142,8 +142,9 @@ export async function probeDiscord(
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
let res: Response | undefined;
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
res = await fetchWithTimeout(
|
||||
`${DISCORD_API_BASE}/users/@me`,
|
||||
{ headers: { Authorization: `Bot ${normalized}` } },
|
||||
timeoutMs,
|
||||
@@ -172,6 +173,10 @@ export async function probeDiscord(
|
||||
error: formatErrorMessage(err),
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
} finally {
|
||||
if (res?.bodyUsed !== true) {
|
||||
await res?.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user